preflight-mcp 0.4.3 → 0.5.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 CHANGED
@@ -51,7 +51,8 @@ Preflight: 🔗 Trace links:
51
51
  - 📖 **Auto-generated guides** — `START_HERE.md`, `AGENTS.md`, `OVERVIEW.md`
52
52
  - ☁️ **Cloud sync** — Multi-path mirror backup for redundancy
53
53
  - 🧠 **EDDA (Evidence-Driven Deep Analysis)** — Auto-generate auditable claims with evidence
54
- - ⚡ **18 MCP tools + 5 prompts** — Complete toolkit for code exploration
54
+ - ⚡ **22 MCP tools + 5 prompts** — Complete toolkit for code exploration
55
+ - 📄 **Cursor pagination** — Handle large result sets efficiently (RFC v2)
55
56
 
56
57
  <details>
57
58
  <summary><b>All Features (click to expand)</b></summary>
@@ -75,7 +76,7 @@ Preflight: 🔗 Trace links:
75
76
  - [Demo](#demo)
76
77
  - [Core Features](#core-features)
77
78
  - [Quick Start](#quick-start)
78
- - [Tools](#tools-15-total)
79
+ - [Tools](#tools-22-total)
79
80
  - [Prompts](#prompts-5-total)
80
81
  - [Environment Variables](#environment-variables)
81
82
  - [Contributing](#contributing)
@@ -160,10 +161,11 @@ Run end-to-end smoke test:
160
161
  npm run smoke
161
162
  ```
162
163
 
163
- ## Tools (18 total)
164
+ ## Tools (22 total)
164
165
 
165
166
  ### `preflight_list_bundles`
166
167
  List bundle IDs in storage.
168
+ - **Cursor pagination** (v0.5.0): Use `cursor` parameter for large bundle lists
167
169
  - Triggers: "show bundles", "查看bundle", "有哪些bundle"
168
170
 
169
171
  ### `preflight_create_bundle`
@@ -236,10 +238,15 @@ Important: **this tool is strictly read-only**.
236
238
  - `fileTypeFilters`: Filter by extension (e.g., `[".py", ".ts"]`)
237
239
  - `includeScore`: Include BM25 relevance score in results
238
240
 
241
+ **Cursor pagination** (v0.5.0):
242
+ - `cursor`: Pagination cursor from previous call for fetching next page
243
+ - Response includes `truncation.nextCursor` when more results available
244
+
239
245
  **Deprecated parameters**: `ensureFresh`, `autoRepairIndex`, `maxAgeHours` are deprecated and will return warnings instead of errors.
240
246
 
241
247
  ### `preflight_search_by_tags`
242
248
  Search across multiple bundles filtered by tags (line-based SQLite FTS5).
249
+ - **Cursor pagination** (v0.5.0): Use `cursor` parameter for large result sets
243
250
  - Triggers: "search in MCP bundles", "在MCP项目中搜索", "搜索所有agent"
244
251
 
245
252
  Notes:
@@ -250,6 +257,7 @@ Optional parameters:
250
257
  - `tags`: Filter bundles by tags (e.g., `["mcp", "agents"]`)
251
258
  - `scope`: Search scope (`docs`, `code`, or `all`)
252
259
  - `limit`: Max total hits across all bundles
260
+ - `cursor`: Pagination cursor for fetching next page
253
261
 
254
262
  ### `preflight_evidence_dependency_graph`
255
263
  Generate an evidence-based dependency graph. Two modes:
@@ -280,6 +288,7 @@ Create or update traceability links (code↔test, code↔doc, file↔requirement
280
288
  ### `preflight_trace_query`
281
289
  Query traceability links (code↔test, code↔doc, commit↔ticket).
282
290
  - **Proactive use**: LLM automatically queries trace links when analyzing specific files
291
+ - **Cursor pagination** (v0.5.0): Use `cursor` parameter for large result sets
283
292
  - Returns `reason` and `nextSteps` when no edges found (helps LLM decide next action)
284
293
  - Fast when `bundleId` is provided; can scan across bundles when omitted.
285
294
 
@@ -327,6 +336,37 @@ Parameters:
327
336
  - `verifyFileExists`: Check evidence files exist (default: true)
328
337
  - `strictMode`: Treat warnings as errors (default: false)
329
338
 
339
+ ### `preflight_read_files` *(NEW v0.5.0)*
340
+ Batch read multiple files from a bundle in a single call.
341
+ - Reduces round-trips for evidence gathering
342
+ - **RFC v2 unified envelope**: Returns `ok`, `meta`, `data`, `evidence[]`
343
+ - Triggers: "read these files", "get content of", "批量读取"
344
+
345
+ Parameters:
346
+ - `bundleId`: Bundle ID
347
+ - `files[]`: Array of `{path, ranges?, withLineNumbers?}`
348
+ - `format`: `"json"` (default) or `"text"`
349
+
350
+ ### `preflight_search_and_read` *(NEW v0.5.0)*
351
+ Search + excerpt in one call - finds relevant code and returns context.
352
+ - Combines search with automatic context extraction
353
+ - **RFC v2 unified envelope**: Returns `ok`, `meta`, `data`, `evidence[]`
354
+ - Triggers: "search and show code", "find and read", "搜索并读取"
355
+
356
+ Parameters:
357
+ - `bundleId`: Bundle ID
358
+ - `query`: Search query
359
+ - `contextLines`: Lines of context around matches (default: 5)
360
+ - `maxFiles`: Max files to read (default: 5)
361
+ - `format`: `"json"` (default) or `"text"`
362
+
363
+ ### `preflight_get_dependency_graph` *(Simplified wrapper)*
364
+ Simplified dependency graph query.
365
+ - `scope: "global"` (default): Project-wide graph
366
+ - `scope: "target"` with `targetFile`: Single file dependencies
367
+ - `format: "summary"` (default): Aggregated view
368
+ - `format: "full"`: Raw graph data
369
+
330
370
  ### `preflight_cleanup_orphans`
331
371
  Remove incomplete or corrupted bundles (bundles without valid manifest.json).
332
372
  - Triggers: "clean up broken bundles", "remove orphans", "清理孤儿bundle"
@@ -393,6 +433,11 @@ Common kinds:
393
433
  - `invalid_path` (unsafe path traversal attempt)
394
434
  - `permission_denied`
395
435
  - `index_missing_or_corrupt`
436
+ - `cursor_invalid` *(v0.5.0)*
437
+ - `cursor_expired` *(v0.5.0)*
438
+ - `rate_limited` *(v0.5.0)*
439
+ - `timeout` *(v0.5.0)*
440
+ - `pagination_required` *(v0.5.0)*
396
441
  - `unknown`
397
442
 
398
443
  This is designed so UIs/agents can reliably decide whether to:
@@ -66,6 +66,119 @@ function clampSnippet(s, maxLen) {
66
66
  function normalizeExt(p) {
67
67
  return path.extname(p).toLowerCase();
68
68
  }
69
+ /**
70
+ * Generate a Mermaid flowchart from dependency graph edges.
71
+ * Limited to top N nodes to keep output readable.
72
+ */
73
+ function generateMermaidDiagram(edges, maxNodes = 20) {
74
+ // Count imports per file (both as importer and imported)
75
+ const importerCounts = new Map();
76
+ const importedCounts = new Map();
77
+ for (const e of edges) {
78
+ if (e.type === 'imports' && e.from && e.to) {
79
+ importerCounts.set(e.from, (importerCounts.get(e.from) ?? 0) + 1);
80
+ importedCounts.set(e.to, (importedCounts.get(e.to) ?? 0) + 1);
81
+ }
82
+ }
83
+ // Get top nodes by total connections
84
+ const allNodes = new Set([...importerCounts.keys(), ...importedCounts.keys()]);
85
+ const nodeScores = Array.from(allNodes).map(n => ({
86
+ node: n,
87
+ score: (importerCounts.get(n) ?? 0) + (importedCounts.get(n) ?? 0),
88
+ })).sort((a, b) => b.score - a.score).slice(0, maxNodes);
89
+ const topNodes = new Set(nodeScores.map(n => n.node));
90
+ // Filter edges to only include top nodes
91
+ const filteredEdges = edges.filter(e => e.type === 'imports' && e.from && e.to &&
92
+ topNodes.has(e.from) && topNodes.has(e.to));
93
+ if (filteredEdges.length === 0) {
94
+ return '```mermaid\nflowchart LR\n A[No edges to display]\n```';
95
+ }
96
+ // Generate Mermaid syntax
97
+ const lines = ['```mermaid', 'flowchart LR'];
98
+ // Create node IDs (sanitize file names)
99
+ const nodeIds = new Map();
100
+ let idCounter = 0;
101
+ const getNodeId = (name) => {
102
+ if (!nodeIds.has(name)) {
103
+ nodeIds.set(name, `N${idCounter++}`);
104
+ }
105
+ return nodeIds.get(name);
106
+ };
107
+ // Add edges
108
+ const addedEdges = new Set();
109
+ for (const e of filteredEdges) {
110
+ const fromId = getNodeId(e.from);
111
+ const toId = getNodeId(e.to);
112
+ const edgeKey = `${fromId}->${toId}`;
113
+ if (!addedEdges.has(edgeKey)) {
114
+ addedEdges.add(edgeKey);
115
+ lines.push(` ${fromId} --> ${toId}`);
116
+ }
117
+ }
118
+ // Add node labels
119
+ for (const [name, id] of nodeIds) {
120
+ // Extract just the filename for readability
121
+ const shortName = name.split('/').pop() ?? name;
122
+ const safeName = shortName.replace(/["]/g, "'").slice(0, 30);
123
+ lines.push(` ${id}["${safeName}"]`);
124
+ }
125
+ lines.push('```');
126
+ return lines.join('\n');
127
+ }
128
+ /**
129
+ * Identify high-value modules from dependency graph.
130
+ */
131
+ function identifyHighValueModules(edges, nodes) {
132
+ const modules = [];
133
+ // Count imports (as importer and as imported)
134
+ const importerCounts = new Map();
135
+ const importedCounts = new Map();
136
+ for (const e of edges) {
137
+ if (e.type === 'imports' && e.from && e.to) {
138
+ importerCounts.set(e.from, (importerCounts.get(e.from) ?? 0) + 1);
139
+ importedCounts.set(e.to, (importedCounts.get(e.to) ?? 0) + 1);
140
+ }
141
+ }
142
+ // High coupling: files imported by many others (>10)
143
+ for (const [file, count] of importedCounts) {
144
+ if (count >= 10) {
145
+ modules.push({
146
+ file,
147
+ reason: 'high_coupling',
148
+ metric: count,
149
+ description: `Imported by ${count} files - core module, changes affect many dependents`,
150
+ });
151
+ }
152
+ }
153
+ // Hub modules: files that import many others (>15)
154
+ for (const [file, count] of importerCounts) {
155
+ if (count >= 15) {
156
+ modules.push({
157
+ file,
158
+ reason: 'hub',
159
+ metric: count,
160
+ description: `Imports ${count} modules - orchestrator/entry point, understand dependencies first`,
161
+ });
162
+ }
163
+ }
164
+ // Entry points: high importer count but low imported count
165
+ for (const [file, importCount] of importerCounts) {
166
+ const importedCount = importedCounts.get(file) ?? 0;
167
+ if (importCount >= 8 && importedCount <= 2) {
168
+ // Avoid duplicates
169
+ if (!modules.some(m => m.file === file && m.reason === 'entry_point')) {
170
+ modules.push({
171
+ file,
172
+ reason: 'entry_point',
173
+ metric: importCount,
174
+ description: `Likely entry point: imports ${importCount} modules but only imported by ${importedCount}`,
175
+ });
176
+ }
177
+ }
178
+ }
179
+ // Sort by metric descending, limit to top 10
180
+ return modules.sort((a, b) => b.metric - a.metric).slice(0, 10);
181
+ }
69
182
  function parseRepoNormPath(bundleRelativePath) {
70
183
  const p = bundleRelativePath.replaceAll('\\', '/').replace(/^\/+/, '');
71
184
  const parts = p.split('/').filter(Boolean);
@@ -1196,6 +1309,10 @@ async function generateGlobalDependencyGraph(ctx) {
1196
1309
  timeBudgetMs,
1197
1310
  },
1198
1311
  };
1312
+ // Generate high-value modules and Mermaid diagram
1313
+ const nodesArray = Array.from(nodes.values());
1314
+ const highValueModules = identifyHighValueModules(edges, nodesArray);
1315
+ const mermaid = edges.length > 0 ? generateMermaidDiagram(edges, 15) : undefined;
1199
1316
  return {
1200
1317
  meta: {
1201
1318
  requestId,
@@ -1216,7 +1333,7 @@ async function generateGlobalDependencyGraph(ctx) {
1216
1333
  },
1217
1334
  },
1218
1335
  facts: {
1219
- nodes: Array.from(nodes.values()),
1336
+ nodes: nodesArray,
1220
1337
  edges,
1221
1338
  },
1222
1339
  signals: {
@@ -1228,8 +1345,10 @@ async function generateGlobalDependencyGraph(ctx) {
1228
1345
  importEdges,
1229
1346
  },
1230
1347
  warnings,
1348
+ highValueModules: highValueModules.length > 0 ? highValueModules : undefined,
1231
1349
  },
1232
1350
  coverageReport,
1351
+ mermaid,
1233
1352
  };
1234
1353
  }
1235
1354
  /**
@@ -0,0 +1,145 @@
1
+ /**
2
+ * RFC v2: Cursor encoding/decoding for stable pagination.
3
+ *
4
+ * Cursors are opaque, base64-encoded JSON objects that contain:
5
+ * - offset: The position in the result set
6
+ * - sortKey: The last seen sort key for stable ordering
7
+ * - tool: The tool that generated the cursor (for validation)
8
+ * - timestamp: When the cursor was created (for expiration)
9
+ *
10
+ * This enables LLMs to reliably paginate through large result sets.
11
+ */
12
+ /**
13
+ * Maximum cursor age in milliseconds (24 hours).
14
+ * Cursors older than this are considered expired.
15
+ */
16
+ const MAX_CURSOR_AGE_MS = 24 * 60 * 60 * 1000;
17
+ /**
18
+ * Encode cursor state to an opaque string.
19
+ */
20
+ export function encodeCursor(state) {
21
+ const json = JSON.stringify(state);
22
+ // Use base64url encoding (URL-safe, no padding)
23
+ return Buffer.from(json, 'utf8').toString('base64url');
24
+ }
25
+ /**
26
+ * Decode cursor string to cursor state.
27
+ * Returns null if the cursor is invalid.
28
+ */
29
+ export function decodeCursor(cursor) {
30
+ try {
31
+ const json = Buffer.from(cursor, 'base64url').toString('utf8');
32
+ const state = JSON.parse(json);
33
+ // Validate structure
34
+ if (typeof state !== 'object' ||
35
+ state === null ||
36
+ typeof state.offset !== 'number' ||
37
+ typeof state.tool !== 'string' ||
38
+ typeof state.timestamp !== 'number') {
39
+ return null;
40
+ }
41
+ return state;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /**
48
+ * Validate a cursor for a specific tool.
49
+ * Checks structure, tool match, and expiration.
50
+ */
51
+ export function validateCursor(cursor, expectedTool, options) {
52
+ const state = decodeCursor(cursor);
53
+ if (!state) {
54
+ return {
55
+ valid: false,
56
+ error: 'Invalid cursor format',
57
+ };
58
+ }
59
+ // Check tool match
60
+ if (!options?.allowToolMismatch && state.tool !== expectedTool) {
61
+ return {
62
+ valid: false,
63
+ state,
64
+ error: `Cursor was created by ${state.tool}, not ${expectedTool}`,
65
+ };
66
+ }
67
+ // Check expiration
68
+ const maxAge = options?.maxAgeMs ?? MAX_CURSOR_AGE_MS;
69
+ const age = Date.now() - state.timestamp;
70
+ if (age > maxAge) {
71
+ return {
72
+ valid: false,
73
+ state,
74
+ error: `Cursor expired (age: ${Math.round(age / 1000)}s, max: ${Math.round(maxAge / 1000)}s)`,
75
+ };
76
+ }
77
+ return {
78
+ valid: true,
79
+ state,
80
+ };
81
+ }
82
+ /**
83
+ * Create a cursor for the next page of results.
84
+ *
85
+ * @param tool - The tool creating the cursor
86
+ * @param offset - Current offset (will be incremented by pageSize)
87
+ * @param pageSize - Number of items per page
88
+ * @param sortKey - Optional sort key for keyset pagination
89
+ * @param extra - Optional additional data
90
+ */
91
+ export function createNextCursor(tool, offset, pageSize, sortKey, extra) {
92
+ const state = {
93
+ offset: offset + pageSize,
94
+ tool,
95
+ timestamp: Date.now(),
96
+ };
97
+ if (sortKey !== undefined)
98
+ state.sortKey = sortKey;
99
+ if (extra !== undefined)
100
+ state.extra = extra;
101
+ return encodeCursor(state);
102
+ }
103
+ /**
104
+ * Parse cursor or return default offset.
105
+ * Convenience function for tool handlers.
106
+ *
107
+ * @param cursor - Optional cursor string
108
+ * @param tool - Expected tool name
109
+ * @param defaultOffset - Default offset if no cursor (default: 0)
110
+ * @returns Offset to use and any error message
111
+ */
112
+ export function parseCursorOrDefault(cursor, tool, defaultOffset = 0) {
113
+ if (!cursor) {
114
+ return { offset: defaultOffset };
115
+ }
116
+ const validation = validateCursor(cursor, tool);
117
+ if (!validation.valid) {
118
+ return { offset: defaultOffset, error: validation.error };
119
+ }
120
+ return {
121
+ offset: validation.state.offset,
122
+ sortKey: validation.state.sortKey,
123
+ extra: validation.state.extra,
124
+ };
125
+ }
126
+ /**
127
+ * Helper to determine if pagination should continue.
128
+ *
129
+ * @param returnedCount - Number of items returned in this page
130
+ * @param limit - Requested limit
131
+ * @param totalCount - Optional total count (if known)
132
+ * @param currentOffset - Current offset in result set
133
+ */
134
+ export function shouldPaginate(returnedCount, limit, totalCount, currentOffset = 0) {
135
+ // If we got fewer items than requested, we're at the end
136
+ if (returnedCount < limit) {
137
+ return false;
138
+ }
139
+ // If we know the total and have fetched everything, no more pages
140
+ if (totalCount !== undefined && currentOffset + returnedCount >= totalCount) {
141
+ return false;
142
+ }
143
+ // Otherwise, assume there might be more
144
+ return true;
145
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * RFC v2: Unified Response Envelope for all Preflight MCP tools.
3
+ *
4
+ * This module defines the standardized response structure that enables:
5
+ * - LLM-friendly JSON output with stable field names
6
+ * - Evidence-first design with traceable citations
7
+ * - Pagination/truncation support with cursor-based continuation
8
+ * - Structured error handling with recovery hints
9
+ */
10
+ /**
11
+ * Schema version for response envelope.
12
+ * Increment when making breaking changes to envelope structure.
13
+ */
14
+ export const SCHEMA_VERSION = '2.0';
15
+ /**
16
+ * Type guard to check if response is successful.
17
+ */
18
+ export function isSuccessResponse(response) {
19
+ return response.ok === true && response.data !== undefined;
20
+ }
21
+ /**
22
+ * Type guard to check if response is an error.
23
+ */
24
+ export function isErrorResponse(response) {
25
+ return response.ok === false && response.error !== undefined;
26
+ }
27
+ /**
28
+ * Helper to create a source range from line numbers.
29
+ */
30
+ export function createRange(startLine, endLine, startCol, endCol) {
31
+ const range = { startLine, endLine };
32
+ if (startCol !== undefined)
33
+ range.startCol = startCol;
34
+ if (endCol !== undefined)
35
+ range.endCol = endCol;
36
+ return range;
37
+ }
38
+ /**
39
+ * Helper to create an evidence pointer.
40
+ */
41
+ export function createEvidencePointer(path, range, options) {
42
+ const pointer = { path, range };
43
+ if (options?.uri)
44
+ pointer.uri = options.uri;
45
+ if (options?.snippet)
46
+ pointer.snippet = options.snippet;
47
+ if (options?.snippetSha256)
48
+ pointer.snippetSha256 = options.snippetSha256;
49
+ return pointer;
50
+ }
51
+ /**
52
+ * Format evidence pointer as citation string.
53
+ * Format: "path:startLine-endLine" or "path:line" for single line
54
+ */
55
+ export function formatEvidenceCitation(pointer) {
56
+ const { path, range } = pointer;
57
+ if (range.startLine === range.endLine) {
58
+ return `${path}:${range.startLine}`;
59
+ }
60
+ return `${path}:${range.startLine}-${range.endLine}`;
61
+ }
@@ -72,6 +72,41 @@ const LLM_RECOVERY_HINTS = {
72
72
  This parameter is deprecated. The tool is now strictly read-only.
73
73
  - For updates: use preflight_update_bundle first, then retry
74
74
  - For repairs: use preflight_repair_bundle first, then retry`,
75
+ // RFC v2 additions
76
+ cursor_invalid: `💡 Recovery steps:
77
+ 1. The cursor format is invalid or corrupted
78
+ 2. Start fresh without a cursor to get the first page
79
+ 3. Use the nextCursor from the previous response exactly as provided`,
80
+ cursor_expired: `💡 Recovery steps:
81
+ 1. Cursors expire after 24 hours
82
+ 2. Start fresh without a cursor to get the first page
83
+ 3. Complete pagination within a reasonable time window`,
84
+ cursor_tool_mismatch: `💡 Recovery steps:
85
+ 1. The cursor was created by a different tool
86
+ 2. Use the cursor only with the same tool that created it
87
+ 3. Start fresh without a cursor for this tool`,
88
+ rate_limited: `💡 Recovery steps:
89
+ 1. You are making requests too quickly
90
+ 2. Wait a few seconds before retrying
91
+ 3. Consider batching multiple operations into single calls`,
92
+ timeout: `💡 Recovery steps:
93
+ 1. The operation took too long to complete
94
+ 2. Try with a smaller scope or limit
95
+ 3. Use cursor pagination to process in smaller batches`,
96
+ pagination_required: `💡 Note:
97
+ The result set is large. Use cursor pagination:
98
+ 1. Check the 'truncation' field in the response
99
+ 2. Pass the 'nextCursor' value in subsequent calls
100
+ 3. Continue until truncated=false`,
101
+ validation_error: `💡 Recovery steps:
102
+ 1. Check that all required parameters are provided
103
+ 2. Verify parameter types match the schema
104
+ 3. Review the error message for specific field issues`,
105
+ partial_success: `💡 Note:
106
+ Some operations succeeded but others failed:
107
+ 1. Check the 'warnings' array for details on failed items
108
+ 2. Address individual issues and retry failed items
109
+ 3. Successfully processed items are already applied`,
75
110
  unknown: `💡 If this error persists:
76
111
  1. Check the error message for specific details
77
112
  2. Verify your input parameters match the tool's schema
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Path redaction utilities for RFC v2.
3
+ *
4
+ * Provides functions to sanitize paths before returning them to LLM clients,
5
+ * removing sensitive information like usernames, home directories, etc.
6
+ */
7
+ const DEFAULT_OPTIONS = {
8
+ redactUsername: true,
9
+ redactAbsolutePrefix: false,
10
+ customPatterns: [],
11
+ keepLastSegments: 0,
12
+ };
13
+ /**
14
+ * Common home directory patterns across platforms.
15
+ */
16
+ const HOME_DIR_PATTERNS = [
17
+ // Windows: C:\Users\username\...
18
+ /^[A-Za-z]:\\Users\\[^\\]+\\/i,
19
+ // Linux/macOS: /home/username/... or /Users/username/...
20
+ /^\/(?:home|Users)\/[^/]+\//,
21
+ // WSL: /mnt/c/Users/username/...
22
+ /^\/mnt\/[a-z]\/Users\/[^/]+\//i,
23
+ ];
24
+ /**
25
+ * Redact sensitive information from a file path.
26
+ *
27
+ * @param path - The file path to redact
28
+ * @param options - Redaction options
29
+ * @returns The redacted path
30
+ *
31
+ * @example
32
+ * redactPath('/Users/john/projects/myapp/src/main.ts')
33
+ * // Returns: '~/projects/myapp/src/main.ts'
34
+ *
35
+ * redactPath('C:\\Users\\john\\projects\\myapp\\src\\main.ts')
36
+ * // Returns: '~\\projects\\myapp\\src\\main.ts'
37
+ */
38
+ export function redactPath(path, options = {}) {
39
+ const opts = { ...DEFAULT_OPTIONS, ...options };
40
+ let result = path;
41
+ // Apply username redaction
42
+ if (opts.redactUsername) {
43
+ for (const pattern of HOME_DIR_PATTERNS) {
44
+ result = result.replace(pattern, (match) => {
45
+ // Preserve the path separator style
46
+ return match.includes('\\') ? '~\\' : '~/';
47
+ });
48
+ }
49
+ }
50
+ // Apply absolute prefix redaction
51
+ if (opts.redactAbsolutePrefix) {
52
+ // Remove drive letters on Windows
53
+ result = result.replace(/^[A-Za-z]:/, '');
54
+ // Remove leading slash on Unix
55
+ result = result.replace(/^\//, '');
56
+ }
57
+ // Apply custom patterns
58
+ for (const { pattern, replacement } of opts.customPatterns ?? []) {
59
+ result = result.replace(pattern, replacement);
60
+ }
61
+ // Keep only last N segments
62
+ if (opts.keepLastSegments && opts.keepLastSegments > 0) {
63
+ const separator = result.includes('\\') ? '\\' : '/';
64
+ const segments = result.split(/[/\\]/);
65
+ if (segments.length > opts.keepLastSegments) {
66
+ result = '...' + separator + segments.slice(-opts.keepLastSegments).join(separator);
67
+ }
68
+ }
69
+ return result;
70
+ }
71
+ /**
72
+ * Redact paths in an object recursively.
73
+ * Looks for common path-like keys: path, file, filePath, dir, directory, uri, etc.
74
+ *
75
+ * @param obj - Object to redact paths in
76
+ * @param options - Redaction options
77
+ * @returns New object with redacted paths
78
+ */
79
+ export function redactPathsInObject(obj, options = {}) {
80
+ if (obj === null || obj === undefined) {
81
+ return obj;
82
+ }
83
+ if (typeof obj === 'string') {
84
+ // Check if it looks like a path
85
+ if (isLikelyPath(obj)) {
86
+ return redactPath(obj, options);
87
+ }
88
+ return obj;
89
+ }
90
+ if (Array.isArray(obj)) {
91
+ return obj.map(item => redactPathsInObject(item, options));
92
+ }
93
+ if (typeof obj === 'object') {
94
+ const result = {};
95
+ for (const [key, value] of Object.entries(obj)) {
96
+ if (isPathKey(key) && typeof value === 'string') {
97
+ result[key] = redactPath(value, options);
98
+ }
99
+ else {
100
+ result[key] = redactPathsInObject(value, options);
101
+ }
102
+ }
103
+ return result;
104
+ }
105
+ return obj;
106
+ }
107
+ /**
108
+ * Check if a key name typically contains a path value.
109
+ */
110
+ function isPathKey(key) {
111
+ const pathKeys = [
112
+ 'path',
113
+ 'file',
114
+ 'filePath',
115
+ 'filepath',
116
+ 'dir',
117
+ 'directory',
118
+ 'folder',
119
+ 'rootDir',
120
+ 'rootPath',
121
+ 'absPath',
122
+ 'absolutePath',
123
+ 'relativePath',
124
+ 'relPath',
125
+ 'bundleRoot',
126
+ 'storageDir',
127
+ 'configPath',
128
+ ];
129
+ const lowerKey = key.toLowerCase();
130
+ return pathKeys.some(pk => lowerKey === pk.toLowerCase() || lowerKey.endsWith(pk.toLowerCase()));
131
+ }
132
+ /**
133
+ * Check if a string looks like a file path.
134
+ */
135
+ function isLikelyPath(str) {
136
+ // Check for common path patterns
137
+ return (
138
+ // Windows path: C:\...
139
+ /^[A-Za-z]:[/\\]/.test(str) ||
140
+ // Unix absolute path: /...
141
+ str.startsWith('/') ||
142
+ // Contains multiple path separators
143
+ (str.includes('/') && str.split('/').length > 2) ||
144
+ (str.includes('\\') && str.split('\\').length > 2));
145
+ }
146
+ /**
147
+ * Create a redaction function with preset options.
148
+ *
149
+ * @param options - Default options for the redactor
150
+ * @returns A redactPath function with the given options preset
151
+ */
152
+ export function createRedactor(options) {
153
+ return (path) => redactPath(path, options);
154
+ }
155
+ /**
156
+ * Check if a path appears to contain sensitive information.
157
+ *
158
+ * @param path - Path to check
159
+ * @returns True if the path might contain sensitive info
160
+ */
161
+ export function containsSensitiveInfo(path) {
162
+ // Check for home directory patterns
163
+ for (const pattern of HOME_DIR_PATTERNS) {
164
+ if (pattern.test(path)) {
165
+ return true;
166
+ }
167
+ }
168
+ // Check for other potentially sensitive patterns
169
+ const sensitivePatterns = [
170
+ /\.ssh\//i,
171
+ /\.gnupg\//i,
172
+ /\.aws\//i,
173
+ /\.azure\//i,
174
+ /credentials/i,
175
+ /secrets?/i,
176
+ /private/i,
177
+ /\.env$/i,
178
+ ];
179
+ return sensitivePatterns.some(p => p.test(path));
180
+ }