agileflow 2.91.0 → 2.92.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +6 -6
  3. package/lib/README.md +178 -0
  4. package/lib/codebase-indexer.js +32 -23
  5. package/lib/colors.js +190 -12
  6. package/lib/consent.js +232 -0
  7. package/lib/correlation.js +277 -0
  8. package/lib/error-codes.js +46 -0
  9. package/lib/errors.js +48 -6
  10. package/lib/file-cache.js +182 -0
  11. package/lib/format-error.js +156 -0
  12. package/lib/path-resolver.js +155 -7
  13. package/lib/paths.js +212 -20
  14. package/lib/placeholder-registry.js +205 -0
  15. package/lib/registry-di.js +358 -0
  16. package/lib/result-schema.js +363 -0
  17. package/lib/result.js +210 -0
  18. package/lib/session-registry.js +13 -0
  19. package/lib/session-state-machine.js +465 -0
  20. package/lib/validate-commands.js +308 -0
  21. package/lib/validate.js +116 -52
  22. package/package.json +1 -1
  23. package/scripts/af +34 -0
  24. package/scripts/agent-loop.js +63 -9
  25. package/scripts/agileflow-configure.js +2 -2
  26. package/scripts/agileflow-welcome.js +491 -23
  27. package/scripts/archive-completed-stories.sh +57 -11
  28. package/scripts/claude-tmux.sh +102 -0
  29. package/scripts/damage-control-bash.js +3 -70
  30. package/scripts/damage-control-edit.js +3 -20
  31. package/scripts/damage-control-write.js +3 -20
  32. package/scripts/dependency-check.js +310 -0
  33. package/scripts/get-env.js +11 -4
  34. package/scripts/lib/configure-detect.js +23 -1
  35. package/scripts/lib/configure-features.js +50 -2
  36. package/scripts/lib/context-formatter.js +771 -0
  37. package/scripts/lib/context-loader.js +699 -0
  38. package/scripts/lib/damage-control-utils.js +107 -0
  39. package/scripts/lib/json-utils.sh +162 -0
  40. package/scripts/lib/state-migrator.js +353 -0
  41. package/scripts/lib/story-state-machine.js +437 -0
  42. package/scripts/obtain-context.js +80 -1248
  43. package/scripts/pre-push-check.sh +46 -0
  44. package/scripts/precompact-context.sh +23 -10
  45. package/scripts/query-codebase.js +127 -14
  46. package/scripts/ralph-loop.js +5 -5
  47. package/scripts/session-manager.js +408 -55
  48. package/scripts/spawn-parallel.js +666 -0
  49. package/scripts/tui/blessed/data/watcher.js +20 -15
  50. package/scripts/tui/blessed/index.js +2 -2
  51. package/scripts/tui/blessed/panels/output.js +14 -8
  52. package/scripts/tui/blessed/panels/sessions.js +22 -15
  53. package/scripts/tui/blessed/panels/trace.js +14 -8
  54. package/scripts/tui/blessed/ui/help.js +3 -3
  55. package/scripts/tui/blessed/ui/screen.js +4 -4
  56. package/scripts/tui/blessed/ui/statusbar.js +5 -9
  57. package/scripts/tui/blessed/ui/tabbar.js +11 -11
  58. package/scripts/validators/component-validator.js +41 -14
  59. package/scripts/validators/json-schema-validator.js +11 -4
  60. package/scripts/validators/markdown-validator.js +1 -2
  61. package/scripts/validators/migration-validator.js +17 -5
  62. package/scripts/validators/security-validator.js +137 -33
  63. package/scripts/validators/story-format-validator.js +31 -10
  64. package/scripts/validators/test-result-validator.js +19 -4
  65. package/scripts/validators/workflow-validator.js +12 -5
  66. package/src/core/agents/codebase-query.md +24 -0
  67. package/src/core/commands/adr.md +114 -0
  68. package/src/core/commands/agent.md +120 -0
  69. package/src/core/commands/assign.md +145 -0
  70. package/src/core/commands/babysit.md +32 -5
  71. package/src/core/commands/changelog.md +118 -0
  72. package/src/core/commands/configure.md +42 -6
  73. package/src/core/commands/diagnose.md +114 -0
  74. package/src/core/commands/epic.md +113 -0
  75. package/src/core/commands/handoff.md +128 -0
  76. package/src/core/commands/help.md +75 -0
  77. package/src/core/commands/pr.md +96 -0
  78. package/src/core/commands/roadmap/analyze.md +400 -0
  79. package/src/core/commands/session/new.md +132 -6
  80. package/src/core/commands/session/spawn.md +197 -0
  81. package/src/core/commands/sprint.md +22 -0
  82. package/src/core/commands/status.md +74 -0
  83. package/src/core/commands/story.md +143 -4
  84. package/src/core/templates/agileflow-metadata.json +55 -2
  85. package/src/core/templates/plan-template.md +125 -0
  86. package/src/core/templates/story-lifecycle.md +213 -0
  87. package/src/core/templates/story-template.md +4 -0
  88. package/src/core/templates/tdd-test-template.js +241 -0
  89. package/tools/cli/commands/setup.js +95 -0
  90. package/tools/cli/installers/core/installer.js +94 -0
  91. package/tools/cli/installers/ide/_base-ide.js +20 -11
  92. package/tools/cli/installers/ide/codex.js +29 -47
  93. package/tools/cli/installers/ide/windsurf.js +1 -1
  94. package/tools/cli/lib/config-manager.js +17 -2
  95. package/tools/cli/lib/content-transformer.js +271 -0
  96. package/tools/cli/lib/error-handler.js +14 -22
  97. package/tools/cli/lib/ide-error-factory.js +421 -0
  98. package/tools/cli/lib/ide-health-monitor.js +364 -0
  99. package/tools/cli/lib/ide-registry.js +113 -2
  100. package/tools/cli/lib/ui.js +15 -25
package/lib/consent.js ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * consent.js
3
+ *
4
+ * GDPR consent handling for AgileFlow (US-0149)
5
+ *
6
+ * Manages privacy consent during setup:
7
+ * - Prompts user to acknowledge privacy policy
8
+ * - Stores consent timestamp in .agileflow/config/consent.json
9
+ * - Supports --accept-privacy flag for CI environments
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const readline = require('readline');
15
+
16
+ /**
17
+ * Consent configuration
18
+ */
19
+ const CONSENT_FILE = '.agileflow/config/consent.json';
20
+ const PRIVACY_POLICY_URL =
21
+ 'https://github.com/projectquestorg/AgileFlow/blob/main/packages/cli/PRIVACY.md';
22
+
23
+ /**
24
+ * Consent status types
25
+ */
26
+ const ConsentStatus = {
27
+ ACCEPTED: 'accepted',
28
+ DECLINED: 'declined',
29
+ PENDING: 'pending',
30
+ };
31
+
32
+ /**
33
+ * Check if consent has been given
34
+ * @returns {{ hasConsent: boolean, consent: Object | null }}
35
+ */
36
+ function checkConsent() {
37
+ const consentPath = path.resolve(process.cwd(), CONSENT_FILE);
38
+
39
+ try {
40
+ if (fs.existsSync(consentPath)) {
41
+ const consent = JSON.parse(fs.readFileSync(consentPath, 'utf8'));
42
+ return {
43
+ hasConsent: consent.status === ConsentStatus.ACCEPTED,
44
+ consent,
45
+ };
46
+ }
47
+ } catch {
48
+ // Ignore errors, treat as no consent
49
+ }
50
+
51
+ return { hasConsent: false, consent: null };
52
+ }
53
+
54
+ /**
55
+ * Record consent
56
+ * @param {string} status - 'accepted' or 'declined'
57
+ * @param {Object} options - Additional options
58
+ * @param {string} options.method - How consent was given ('interactive', 'flag', 'api')
59
+ * @param {string} options.version - Privacy policy version
60
+ * @returns {{ ok: boolean, path: string }}
61
+ */
62
+ function recordConsent(status, options = {}) {
63
+ const { method = 'interactive', version = '1.0.0' } = options;
64
+
65
+ const consentPath = path.resolve(process.cwd(), CONSENT_FILE);
66
+ const consentDir = path.dirname(consentPath);
67
+
68
+ // Ensure directory exists
69
+ if (!fs.existsSync(consentDir)) {
70
+ fs.mkdirSync(consentDir, { recursive: true });
71
+ }
72
+
73
+ const consent = {
74
+ status,
75
+ timestamp: new Date().toISOString(),
76
+ method,
77
+ policy_version: version,
78
+ policy_url: PRIVACY_POLICY_URL,
79
+ };
80
+
81
+ try {
82
+ fs.writeFileSync(consentPath, JSON.stringify(consent, null, 2));
83
+ return { ok: true, path: consentPath };
84
+ } catch (err) {
85
+ return { ok: false, error: err.message };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Prompt user for consent interactively
91
+ * @param {Object} options - Prompt options
92
+ * @param {WritableStream} options.output - Output stream (default: process.stdout)
93
+ * @param {ReadableStream} options.input - Input stream (default: process.stdin)
94
+ * @returns {Promise<{ accepted: boolean }>}
95
+ */
96
+ async function promptConsent(options = {}) {
97
+ const { output = process.stdout, input = process.stdin } = options;
98
+
99
+ const rl = readline.createInterface({
100
+ input,
101
+ output,
102
+ terminal: false,
103
+ });
104
+
105
+ // Display privacy notice
106
+ const notice = `
107
+ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
108
+ ┃ Privacy Notice ┃
109
+ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
110
+
111
+ AgileFlow respects your privacy:
112
+
113
+ • All data is stored locally on your machine
114
+ • No telemetry, analytics, or tracking
115
+ • No data is transmitted to external servers
116
+ • You can delete all data at any time
117
+
118
+ For details, see: ${PRIVACY_POLICY_URL}
119
+
120
+ `;
121
+
122
+ output.write(notice);
123
+
124
+ return new Promise(resolve => {
125
+ const question = 'Do you accept the privacy policy? (yes/no): ';
126
+ output.write(question);
127
+
128
+ rl.once('line', answer => {
129
+ rl.close();
130
+ const normalized = answer.toLowerCase().trim();
131
+ const accepted = normalized === 'yes' || normalized === 'y';
132
+ resolve({ accepted });
133
+ });
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Handle consent during setup
139
+ * @param {Object} options - Setup options
140
+ * @param {boolean} options.acceptPrivacy - --accept-privacy flag was passed
141
+ * @param {boolean} options.silent - Silent mode (no prompts)
142
+ * @param {WritableStream} options.output - Output stream
143
+ * @param {ReadableStream} options.input - Input stream
144
+ * @returns {Promise<{ ok: boolean, status: string, skipped: boolean }>}
145
+ */
146
+ async function handleSetupConsent(options = {}) {
147
+ const {
148
+ acceptPrivacy = false,
149
+ silent = false,
150
+ output = process.stdout,
151
+ input = process.stdin,
152
+ } = options;
153
+
154
+ // Check if consent already given
155
+ const { hasConsent, consent } = checkConsent();
156
+ if (hasConsent) {
157
+ return { ok: true, status: 'already_consented', skipped: false, consent };
158
+ }
159
+
160
+ // If --accept-privacy flag provided
161
+ if (acceptPrivacy) {
162
+ const result = recordConsent(ConsentStatus.ACCEPTED, { method: 'flag' });
163
+ return { ok: result.ok, status: 'accepted_via_flag', skipped: false };
164
+ }
165
+
166
+ // If silent mode (CI without flag)
167
+ if (silent) {
168
+ return { ok: false, status: 'consent_required', skipped: true };
169
+ }
170
+
171
+ // Interactive prompt
172
+ const { accepted } = await promptConsent({ output, input });
173
+
174
+ if (accepted) {
175
+ const result = recordConsent(ConsentStatus.ACCEPTED, { method: 'interactive' });
176
+ return { ok: result.ok, status: 'accepted_interactive', skipped: false };
177
+ } else {
178
+ const result = recordConsent(ConsentStatus.DECLINED, { method: 'interactive' });
179
+ return { ok: false, status: 'declined', skipped: false };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Get consent status for display
185
+ * @returns {{ status: string, timestamp: string | null, method: string | null }}
186
+ */
187
+ function getConsentStatus() {
188
+ const { hasConsent, consent } = checkConsent();
189
+
190
+ if (!consent) {
191
+ return { status: ConsentStatus.PENDING, timestamp: null, method: null };
192
+ }
193
+
194
+ return {
195
+ status: consent.status,
196
+ timestamp: consent.timestamp,
197
+ method: consent.method,
198
+ policyVersion: consent.policy_version,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Revoke consent (delete consent file)
204
+ * @returns {{ ok: boolean }}
205
+ */
206
+ function revokeConsent() {
207
+ const consentPath = path.resolve(process.cwd(), CONSENT_FILE);
208
+
209
+ try {
210
+ if (fs.existsSync(consentPath)) {
211
+ fs.unlinkSync(consentPath);
212
+ }
213
+ return { ok: true };
214
+ } catch (err) {
215
+ return { ok: false, error: err.message };
216
+ }
217
+ }
218
+
219
+ module.exports = {
220
+ // Constants
221
+ CONSENT_FILE,
222
+ PRIVACY_POLICY_URL,
223
+ ConsentStatus,
224
+
225
+ // Functions
226
+ checkConsent,
227
+ recordConsent,
228
+ promptConsent,
229
+ handleSetupConsent,
230
+ getConsentStatus,
231
+ revokeConsent,
232
+ };
@@ -0,0 +1,277 @@
1
+ /**
2
+ * AgileFlow CLI - Correlation ID Management
3
+ *
4
+ * Generates and propagates trace IDs and session IDs for event correlation.
5
+ * Enables filtering and tracing events across complex workflows.
6
+ */
7
+
8
+ const crypto = require('crypto');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ // Global context for correlation IDs
13
+ let currentContext = {
14
+ traceId: null,
15
+ sessionId: null,
16
+ spanId: null,
17
+ parentSpanId: null,
18
+ };
19
+
20
+ /**
21
+ * Generate a unique trace ID (16 hex chars, like OpenTelemetry standard)
22
+ * @returns {string} Unique trace ID
23
+ */
24
+ function generateTraceId() {
25
+ return crypto.randomBytes(8).toString('hex');
26
+ }
27
+
28
+ /**
29
+ * Generate a span ID (8 hex chars)
30
+ * @returns {string} Unique span ID
31
+ */
32
+ function generateSpanId() {
33
+ return crypto.randomBytes(4).toString('hex');
34
+ }
35
+
36
+ /**
37
+ * Generate a session ID based on timestamp and random component
38
+ * Format: session_YYYYMMDD_HHMMSS_XXXX
39
+ * @returns {string} Session ID
40
+ */
41
+ function generateSessionId() {
42
+ const now = new Date();
43
+ const date = now.toISOString().slice(0, 10).replace(/-/g, '');
44
+ const time = now.toISOString().slice(11, 19).replace(/:/g, '');
45
+ const random = crypto.randomBytes(2).toString('hex');
46
+ return `session_${date}_${time}_${random}`;
47
+ }
48
+
49
+ /**
50
+ * Get current correlation context
51
+ * @returns {{ traceId: string | null, sessionId: string | null, spanId: string | null }}
52
+ */
53
+ function getContext() {
54
+ return { ...currentContext };
55
+ }
56
+
57
+ /**
58
+ * Set correlation context
59
+ * @param {Object} context
60
+ * @param {string} [context.traceId] - Trace ID
61
+ * @param {string} [context.sessionId] - Session ID
62
+ * @param {string} [context.spanId] - Span ID
63
+ * @param {string} [context.parentSpanId] - Parent span ID
64
+ */
65
+ function setContext(context) {
66
+ if (context.traceId !== undefined) currentContext.traceId = context.traceId;
67
+ if (context.sessionId !== undefined) currentContext.sessionId = context.sessionId;
68
+ if (context.spanId !== undefined) currentContext.spanId = context.spanId;
69
+ if (context.parentSpanId !== undefined) currentContext.parentSpanId = context.parentSpanId;
70
+ }
71
+
72
+ /**
73
+ * Start a new trace (generates new trace_id)
74
+ * @param {Object} [options]
75
+ * @param {string} [options.sessionId] - Existing session ID to use
76
+ * @returns {{ traceId: string, sessionId: string, spanId: string }}
77
+ */
78
+ function startTrace(options = {}) {
79
+ const traceId = generateTraceId();
80
+ const sessionId = options.sessionId || currentContext.sessionId || generateSessionId();
81
+ const spanId = generateSpanId();
82
+
83
+ setContext({
84
+ traceId,
85
+ sessionId,
86
+ spanId,
87
+ parentSpanId: null,
88
+ });
89
+
90
+ return { traceId, sessionId, spanId };
91
+ }
92
+
93
+ /**
94
+ * Start a new span within current trace
95
+ * @returns {{ traceId: string, sessionId: string, spanId: string, parentSpanId: string | null }}
96
+ */
97
+ function startSpan() {
98
+ const parentSpanId = currentContext.spanId;
99
+ const spanId = generateSpanId();
100
+
101
+ setContext({
102
+ spanId,
103
+ parentSpanId,
104
+ });
105
+
106
+ return {
107
+ traceId: currentContext.traceId,
108
+ sessionId: currentContext.sessionId,
109
+ spanId,
110
+ parentSpanId,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Inject correlation IDs into an event object
116
+ * @param {Object} event - Event to inject into
117
+ * @returns {Object} Event with correlation IDs
118
+ */
119
+ function injectCorrelation(event) {
120
+ const ctx = getContext();
121
+ return {
122
+ ...event,
123
+ ...(ctx.traceId && { trace_id: ctx.traceId }),
124
+ ...(ctx.sessionId && { session_id: ctx.sessionId }),
125
+ ...(ctx.spanId && { span_id: ctx.spanId }),
126
+ ...(ctx.parentSpanId && { parent_span_id: ctx.parentSpanId }),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Clear correlation context (for testing or new sessions)
132
+ */
133
+ function clearContext() {
134
+ currentContext = {
135
+ traceId: null,
136
+ sessionId: null,
137
+ spanId: null,
138
+ parentSpanId: null,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Load session ID from session-state.json if available
144
+ * @param {string} projectDir - Project directory
145
+ * @returns {string | null} Session ID or null
146
+ */
147
+ function loadSessionId(projectDir) {
148
+ try {
149
+ const sessionStatePath = path.join(projectDir, 'docs', '09-agents', 'session-state.json');
150
+ if (fs.existsSync(sessionStatePath)) {
151
+ const data = JSON.parse(fs.readFileSync(sessionStatePath, 'utf8'));
152
+ return data.session_id || null;
153
+ }
154
+ } catch {
155
+ // Ignore errors
156
+ }
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * Save session ID to session-state.json
162
+ * @param {string} projectDir - Project directory
163
+ * @param {string} sessionId - Session ID to save
164
+ */
165
+ function saveSessionId(projectDir, sessionId) {
166
+ try {
167
+ const sessionStatePath = path.join(projectDir, 'docs', '09-agents', 'session-state.json');
168
+ let data = {};
169
+
170
+ if (fs.existsSync(sessionStatePath)) {
171
+ data = JSON.parse(fs.readFileSync(sessionStatePath, 'utf8'));
172
+ }
173
+
174
+ data.session_id = sessionId;
175
+ data.updated_at = new Date().toISOString();
176
+
177
+ const dir = path.dirname(sessionStatePath);
178
+ if (!fs.existsSync(dir)) {
179
+ fs.mkdirSync(dir, { recursive: true });
180
+ }
181
+
182
+ fs.writeFileSync(sessionStatePath, JSON.stringify(data, null, 2) + '\n');
183
+ } catch {
184
+ // Ignore errors - session ID is not critical
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Initialize correlation context for a project
190
+ * Loads existing session ID or creates new one
191
+ * @param {string} projectDir - Project directory
192
+ * @param {Object} [options]
193
+ * @param {boolean} [options.newTrace=true] - Start new trace
194
+ * @returns {{ traceId: string, sessionId: string }}
195
+ */
196
+ function initializeForProject(projectDir, options = {}) {
197
+ const { newTrace = true } = options;
198
+
199
+ // Load or create session ID
200
+ let sessionId = loadSessionId(projectDir);
201
+ if (!sessionId) {
202
+ sessionId = generateSessionId();
203
+ saveSessionId(projectDir, sessionId);
204
+ }
205
+
206
+ setContext({ sessionId });
207
+
208
+ if (newTrace) {
209
+ const trace = startTrace({ sessionId });
210
+ return { traceId: trace.traceId, sessionId };
211
+ }
212
+
213
+ return {
214
+ traceId: currentContext.traceId || generateTraceId(),
215
+ sessionId,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Extract correlation IDs from an event
221
+ * @param {Object} event - Event object
222
+ * @returns {{ traceId: string | null, sessionId: string | null, spanId: string | null }}
223
+ */
224
+ function extractCorrelation(event) {
225
+ return {
226
+ traceId: event.trace_id || null,
227
+ sessionId: event.session_id || null,
228
+ spanId: event.span_id || null,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Filter events by trace ID
234
+ * @param {Array} events - Array of events
235
+ * @param {string} traceId - Trace ID to filter by
236
+ * @returns {Array} Filtered events
237
+ */
238
+ function filterByTraceId(events, traceId) {
239
+ return events.filter(e => e.trace_id === traceId);
240
+ }
241
+
242
+ /**
243
+ * Filter events by session ID
244
+ * @param {Array} events - Array of events
245
+ * @param {string} sessionId - Session ID to filter by
246
+ * @returns {Array} Filtered events
247
+ */
248
+ function filterBySessionId(events, sessionId) {
249
+ return events.filter(e => e.session_id === sessionId);
250
+ }
251
+
252
+ module.exports = {
253
+ // ID generation
254
+ generateTraceId,
255
+ generateSpanId,
256
+ generateSessionId,
257
+
258
+ // Context management
259
+ getContext,
260
+ setContext,
261
+ clearContext,
262
+ startTrace,
263
+ startSpan,
264
+
265
+ // Event injection/extraction
266
+ injectCorrelation,
267
+ extractCorrelation,
268
+
269
+ // Project integration
270
+ initializeForProject,
271
+ loadSessionId,
272
+ saveSessionId,
273
+
274
+ // Filtering
275
+ filterByTraceId,
276
+ filterBySessionId,
277
+ };
@@ -524,6 +524,48 @@ function formatError(error, options = {}) {
524
524
  return lines.join('\n');
525
525
  }
526
526
 
527
+ /**
528
+ * Create an error result with standardized metadata
529
+ * This factory produces Result objects compatible with result-schema.js
530
+ * @param {string} errorCode - Error code (e.g., 'ENOENT', 'ECONFIG')
531
+ * @param {string} [message] - Optional custom error message
532
+ * @param {Object} [options] - Additional options
533
+ * @param {Object} [options.context] - Additional context about the error
534
+ * @param {Error} [options.cause] - Original error that caused this failure
535
+ * @returns {{ ok: false, error: string, errorCode: string, severity: string, category: string, recoverable: boolean, suggestedFix: string, autoFix: string|null, context?: Object, cause?: Error }}
536
+ */
537
+ function createErrorResult(errorCode, message, options = {}) {
538
+ const codeData = ErrorCodes[errorCode] || ErrorCodes.EUNKNOWN;
539
+
540
+ return {
541
+ ok: false,
542
+ error: message || codeData.message,
543
+ errorCode: codeData.code,
544
+ severity: codeData.severity,
545
+ category: codeData.category,
546
+ recoverable: codeData.recoverable,
547
+ suggestedFix: codeData.suggestedFix,
548
+ autoFix: codeData.autoFix || null,
549
+ context: options.context,
550
+ cause: options.cause,
551
+ };
552
+ }
553
+
554
+ /**
555
+ * Create a success result
556
+ * @template T
557
+ * @param {T} data - The success data
558
+ * @param {Object} [meta] - Optional metadata
559
+ * @returns {{ ok: true, data: T }}
560
+ */
561
+ function createSuccessResult(data, meta = {}) {
562
+ return {
563
+ ok: true,
564
+ data,
565
+ ...meta,
566
+ };
567
+ }
568
+
527
569
  module.exports = {
528
570
  // Enums
529
571
  Severity,
@@ -541,4 +583,8 @@ module.exports = {
541
583
  getSuggestedFix,
542
584
  getAutoFix,
543
585
  formatError,
586
+
587
+ // Result factories (for result-schema.js compatibility)
588
+ createErrorResult,
589
+ createSuccessResult,
544
590
  };
package/lib/errors.js CHANGED
@@ -236,10 +236,13 @@ function getCharName(char) {
236
236
  * @param {string} filePath - Absolute path to JSON file
237
237
  * @param {object} options - Optional settings
238
238
  * @param {*} options.defaultValue - Value to return if file doesn't exist (makes missing file not an error)
239
- * @returns {{ ok: boolean, data?: any, error?: string }}
239
+ * @param {boolean} options.hidePathInError - Hide file path in error messages (security: default false)
240
+ * @param {string} options.context - Context for error messages (alternative to exposing path)
241
+ * @returns {{ ok: boolean, data?: any, error?: string, errorCode?: string }}
240
242
  */
241
243
  function safeReadJSON(filePath, options = {}) {
242
- const { defaultValue } = options;
244
+ const { defaultValue, hidePathInError = false, context } = options;
245
+ const pathDisplay = hidePathInError ? context || 'configuration file' : filePath;
243
246
 
244
247
  try {
245
248
  if (!fs.existsSync(filePath)) {
@@ -247,19 +250,58 @@ function safeReadJSON(filePath, options = {}) {
247
250
  debugLog('safeReadJSON', { filePath, status: 'missing, using default' });
248
251
  return { ok: true, data: defaultValue };
249
252
  }
250
- const error = `File not found: ${filePath}`;
253
+ const error = `File not found: ${pathDisplay}`;
251
254
  debugLog('safeReadJSON', { filePath, error });
252
- return { ok: false, error };
255
+ return { ok: false, error, errorCode: 'ENOENT' };
253
256
  }
254
257
 
255
258
  const content = fs.readFileSync(filePath, 'utf8');
259
+
260
+ // Handle empty files gracefully
261
+ if (!content.trim()) {
262
+ if (defaultValue !== undefined) {
263
+ debugLog('safeReadJSON', { filePath, status: 'empty, using default' });
264
+ return { ok: true, data: defaultValue };
265
+ }
266
+ const error = `File is empty: ${pathDisplay}`;
267
+ debugLog('safeReadJSON', { filePath, error: 'empty file' });
268
+ return { ok: false, error, errorCode: 'EPARSE' };
269
+ }
270
+
256
271
  const data = JSON.parse(content);
257
272
  debugLog('safeReadJSON', { filePath, status: 'success' });
258
273
  return { ok: true, data };
259
274
  } catch (err) {
260
- const error = `Failed to read JSON from ${filePath}: ${err.message}`;
275
+ // Detect specific JSON parse errors
276
+ let errorCode = 'EPARSE';
277
+ let errorMessage = `Failed to parse JSON`;
278
+
279
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
280
+ errorCode = 'EACCES';
281
+ errorMessage = `Permission denied reading file`;
282
+ } else if (err.message && err.message.includes('Unexpected')) {
283
+ // JSON syntax error - don't expose full content
284
+ errorMessage = `Invalid JSON syntax`;
285
+ if (err.message.includes('position')) {
286
+ // Extract position info but not content
287
+ const posMatch = err.message.match(/position (\d+)/);
288
+ if (posMatch) {
289
+ errorMessage = `Invalid JSON syntax near position ${posMatch[1]}`;
290
+ }
291
+ }
292
+ } else if (err.message && err.message.includes('token')) {
293
+ errorMessage = `Invalid JSON: unexpected token`;
294
+ }
295
+
296
+ // Add context if not hiding path
297
+ if (!hidePathInError) {
298
+ errorMessage = `${errorMessage} in ${pathDisplay}`;
299
+ } else if (context) {
300
+ errorMessage = `${errorMessage} in ${context}`;
301
+ }
302
+
261
303
  debugLog('safeReadJSON', { filePath, error: err.message });
262
- return { ok: false, error };
304
+ return { ok: false, error: errorMessage, errorCode };
263
305
  }
264
306
  }
265
307