agileflow 2.90.7 → 2.92.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.
Files changed (144) 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 +818 -0
  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-names.js +3 -3
  22. package/lib/validate.js +116 -52
  23. package/package.json +4 -1
  24. package/scripts/af +34 -0
  25. package/scripts/agent-loop.js +63 -9
  26. package/scripts/agileflow-configure.js +2 -2
  27. package/scripts/agileflow-welcome.js +435 -23
  28. package/scripts/archive-completed-stories.sh +57 -11
  29. package/scripts/claude-tmux.sh +102 -0
  30. package/scripts/damage-control-bash.js +3 -70
  31. package/scripts/damage-control-edit.js +3 -20
  32. package/scripts/damage-control-write.js +3 -20
  33. package/scripts/dependency-check.js +310 -0
  34. package/scripts/get-env.js +11 -4
  35. package/scripts/lib/configure-detect.js +23 -1
  36. package/scripts/lib/configure-features.js +43 -2
  37. package/scripts/lib/context-formatter.js +771 -0
  38. package/scripts/lib/context-loader.js +699 -0
  39. package/scripts/lib/damage-control-utils.js +107 -0
  40. package/scripts/lib/json-utils.sh +162 -0
  41. package/scripts/lib/state-migrator.js +353 -0
  42. package/scripts/lib/story-state-machine.js +437 -0
  43. package/scripts/obtain-context.js +118 -1048
  44. package/scripts/pre-push-check.sh +46 -0
  45. package/scripts/precompact-context.sh +36 -11
  46. package/scripts/query-codebase.js +538 -0
  47. package/scripts/ralph-loop.js +5 -5
  48. package/scripts/session-manager.js +220 -42
  49. package/scripts/spawn-parallel.js +651 -0
  50. package/scripts/tui/blessed/data/watcher.js +180 -0
  51. package/scripts/tui/blessed/index.js +244 -0
  52. package/scripts/tui/blessed/panels/output.js +101 -0
  53. package/scripts/tui/blessed/panels/sessions.js +150 -0
  54. package/scripts/tui/blessed/panels/trace.js +97 -0
  55. package/scripts/tui/blessed/ui/help.js +77 -0
  56. package/scripts/tui/blessed/ui/screen.js +52 -0
  57. package/scripts/tui/blessed/ui/statusbar.js +47 -0
  58. package/scripts/tui/blessed/ui/tabbar.js +99 -0
  59. package/scripts/tui/index.js +38 -30
  60. package/scripts/validators/README.md +143 -0
  61. package/scripts/validators/component-validator.js +239 -0
  62. package/scripts/validators/json-schema-validator.js +186 -0
  63. package/scripts/validators/markdown-validator.js +152 -0
  64. package/scripts/validators/migration-validator.js +129 -0
  65. package/scripts/validators/security-validator.js +380 -0
  66. package/scripts/validators/story-format-validator.js +197 -0
  67. package/scripts/validators/test-result-validator.js +114 -0
  68. package/scripts/validators/workflow-validator.js +247 -0
  69. package/src/core/agents/accessibility.md +6 -0
  70. package/src/core/agents/adr-writer.md +6 -0
  71. package/src/core/agents/analytics.md +6 -0
  72. package/src/core/agents/api.md +6 -0
  73. package/src/core/agents/ci.md +6 -0
  74. package/src/core/agents/codebase-query.md +261 -0
  75. package/src/core/agents/compliance.md +6 -0
  76. package/src/core/agents/configuration-damage-control.md +6 -0
  77. package/src/core/agents/configuration-visual-e2e.md +6 -0
  78. package/src/core/agents/database.md +10 -0
  79. package/src/core/agents/datamigration.md +6 -0
  80. package/src/core/agents/design.md +6 -0
  81. package/src/core/agents/devops.md +6 -0
  82. package/src/core/agents/documentation.md +6 -0
  83. package/src/core/agents/epic-planner.md +6 -0
  84. package/src/core/agents/integrations.md +6 -0
  85. package/src/core/agents/mentor.md +6 -0
  86. package/src/core/agents/mobile.md +6 -0
  87. package/src/core/agents/monitoring.md +6 -0
  88. package/src/core/agents/multi-expert.md +6 -0
  89. package/src/core/agents/performance.md +6 -0
  90. package/src/core/agents/product.md +6 -0
  91. package/src/core/agents/qa.md +6 -0
  92. package/src/core/agents/readme-updater.md +6 -0
  93. package/src/core/agents/refactor.md +6 -0
  94. package/src/core/agents/research.md +6 -0
  95. package/src/core/agents/security.md +6 -0
  96. package/src/core/agents/testing.md +10 -0
  97. package/src/core/agents/ui.md +6 -0
  98. package/src/core/commands/adr.md +114 -0
  99. package/src/core/commands/agent.md +120 -0
  100. package/src/core/commands/assign.md +145 -0
  101. package/src/core/commands/audit.md +401 -0
  102. package/src/core/commands/babysit.md +32 -5
  103. package/src/core/commands/board.md +1 -0
  104. package/src/core/commands/changelog.md +118 -0
  105. package/src/core/commands/configure.md +42 -6
  106. package/src/core/commands/diagnose.md +114 -0
  107. package/src/core/commands/epic.md +205 -1
  108. package/src/core/commands/handoff.md +128 -0
  109. package/src/core/commands/help.md +76 -0
  110. package/src/core/commands/metrics.md +1 -0
  111. package/src/core/commands/pr.md +96 -0
  112. package/src/core/commands/research/analyze.md +1 -0
  113. package/src/core/commands/research/ask.md +2 -0
  114. package/src/core/commands/research/import.md +1 -0
  115. package/src/core/commands/research/list.md +2 -0
  116. package/src/core/commands/research/synthesize.md +584 -0
  117. package/src/core/commands/research/view.md +2 -0
  118. package/src/core/commands/roadmap/analyze.md +400 -0
  119. package/src/core/commands/session/new.md +113 -6
  120. package/src/core/commands/session/spawn.md +197 -0
  121. package/src/core/commands/sprint.md +22 -0
  122. package/src/core/commands/status.md +200 -1
  123. package/src/core/commands/story/list.md +9 -9
  124. package/src/core/commands/story/view.md +1 -0
  125. package/src/core/commands/story.md +143 -4
  126. package/src/core/experts/codebase-query/expertise.yaml +190 -0
  127. package/src/core/experts/codebase-query/question.md +73 -0
  128. package/src/core/experts/codebase-query/self-improve.md +105 -0
  129. package/src/core/templates/agileflow-metadata.json +55 -2
  130. package/src/core/templates/plan-template.md +125 -0
  131. package/src/core/templates/story-lifecycle.md +213 -0
  132. package/src/core/templates/story-template.md +4 -0
  133. package/src/core/templates/tdd-test-template.js +241 -0
  134. package/tools/cli/commands/setup.js +86 -0
  135. package/tools/cli/installers/core/installer.js +94 -0
  136. package/tools/cli/installers/ide/_base-ide.js +20 -11
  137. package/tools/cli/installers/ide/codex.js +29 -47
  138. package/tools/cli/lib/config-manager.js +17 -2
  139. package/tools/cli/lib/content-transformer.js +271 -0
  140. package/tools/cli/lib/error-handler.js +14 -22
  141. package/tools/cli/lib/ide-error-factory.js +421 -0
  142. package/tools/cli/lib/ide-health-monitor.js +364 -0
  143. package/tools/cli/lib/ide-registry.js +114 -1
  144. package/tools/cli/lib/ui.js +14 -25
@@ -0,0 +1,818 @@
1
+ /**
2
+ * Codebase Indexer - Fast index for programmatic codebase queries
3
+ *
4
+ * Features:
5
+ * - Builds index of files with metadata (type, exports, imports, tags)
6
+ * - Incremental updates based on file mtime
7
+ * - LRU cache integration for performance
8
+ * - Persistent storage in .agileflow/cache/codebase-index.json
9
+ * - Configuration via docs/00-meta/agileflow-metadata.json
10
+ *
11
+ * Based on RLM (Recursive Language Models) research:
12
+ * Use programmatic search instead of loading full context.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { LRUCache } = require('./file-cache');
18
+ const { safeReadJSON, safeWriteJSON, debugLog } = require('./errors');
19
+
20
+ // Debug mode via env var
21
+ const DEBUG = process.env.AGILEFLOW_DEBUG === '1';
22
+
23
+ // Index version for migration support
24
+ const INDEX_VERSION = '1.0.0';
25
+
26
+ // Default configuration (can be overridden via agileflow-metadata.json)
27
+ const DEFAULT_CONFIG = {
28
+ ttlMs: 60000, // 1 minute cache TTL (or ttl_hours * 3600000)
29
+ maxCacheSize: 10,
30
+ excludePatterns: [
31
+ 'node_modules/**',
32
+ '.git/**',
33
+ 'dist/**',
34
+ 'build/**',
35
+ 'coverage/**',
36
+ '.agileflow/cache/**',
37
+ '*.log',
38
+ '*.lock',
39
+ ],
40
+ includePatterns: [
41
+ '**/*.js',
42
+ '**/*.ts',
43
+ '**/*.tsx',
44
+ '**/*.jsx',
45
+ '**/*.md',
46
+ '**/*.json',
47
+ '**/*.yaml',
48
+ '**/*.yml',
49
+ ],
50
+ maxFileSizeKb: 500,
51
+ tokenBudget: 15000,
52
+ };
53
+
54
+ /**
55
+ * Load configuration from agileflow-metadata.json if available
56
+ * @param {string} projectRoot - Project root directory
57
+ * @returns {Object} Merged configuration
58
+ */
59
+ function loadConfig(projectRoot) {
60
+ const metadataPath = path.join(projectRoot, 'docs/00-meta/agileflow-metadata.json');
61
+ const metadata = safeReadJSON(metadataPath);
62
+
63
+ if (!metadata.ok || !metadata.data?.features?.codebaseIndex) {
64
+ return DEFAULT_CONFIG;
65
+ }
66
+
67
+ const userConfig = metadata.data.features.codebaseIndex;
68
+
69
+ return {
70
+ ...DEFAULT_CONFIG,
71
+ // Convert ttl_hours to ttlMs if provided
72
+ ttlMs: userConfig.ttl_hours ? userConfig.ttl_hours * 60 * 60 * 1000 : DEFAULT_CONFIG.ttlMs,
73
+ excludePatterns: userConfig.exclude_patterns || DEFAULT_CONFIG.excludePatterns,
74
+ includePatterns: userConfig.include_patterns || DEFAULT_CONFIG.includePatterns,
75
+ maxFileSizeKb: userConfig.max_file_size_kb || DEFAULT_CONFIG.maxFileSizeKb,
76
+ tokenBudget: userConfig.token_budget || DEFAULT_CONFIG.tokenBudget,
77
+ };
78
+ }
79
+
80
+ // Tag patterns for auto-detection from path
81
+ const TAG_PATTERNS = {
82
+ api: /\/(api|routes|endpoints|controllers)\//i,
83
+ ui: /\/(components|ui|views|pages)\//i,
84
+ database: /\/(db|database|models|schema|migrations)\//i,
85
+ auth: /\/(auth|login|session|jwt|oauth)\//i,
86
+ test: /\/(test|tests|__tests__|spec|specs)\//i,
87
+ config: /\/(config|settings|env)\//i,
88
+ lib: /\/(lib|utils|helpers|shared)\//i,
89
+ docs: /\/(docs|documentation)\//i,
90
+ scripts: /\/(scripts|bin|tools)\//i,
91
+ types: /\/(types|typings|interfaces)\//i,
92
+ };
93
+
94
+ // In-memory cache for indices
95
+ const indexCache = new LRUCache({
96
+ maxSize: DEFAULT_CONFIG.maxCacheSize,
97
+ ttlMs: DEFAULT_CONFIG.ttlMs,
98
+ });
99
+
100
+ /**
101
+ * Create empty index structure
102
+ * @returns {Object} Empty index
103
+ */
104
+ function createEmptyIndex(projectRoot) {
105
+ return {
106
+ version: INDEX_VERSION,
107
+ created_at: new Date().toISOString(),
108
+ updated_at: new Date().toISOString(),
109
+ project_root: projectRoot,
110
+ stats: {
111
+ total_files: 0,
112
+ indexed_files: 0,
113
+ build_time_ms: 0,
114
+ },
115
+ files: {},
116
+ tags: {},
117
+ symbols: {
118
+ functions: {},
119
+ classes: {},
120
+ exports: {},
121
+ },
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Get file type from extension
127
+ * @param {string} filePath - File path
128
+ * @returns {string} File type
129
+ */
130
+ function getFileType(filePath) {
131
+ const ext = path.extname(filePath).toLowerCase();
132
+ const typeMap = {
133
+ '.js': 'javascript',
134
+ '.ts': 'typescript',
135
+ '.tsx': 'typescript-react',
136
+ '.jsx': 'javascript-react',
137
+ '.md': 'markdown',
138
+ '.json': 'json',
139
+ '.yaml': 'yaml',
140
+ '.yml': 'yaml',
141
+ '.css': 'css',
142
+ '.scss': 'scss',
143
+ '.html': 'html',
144
+ };
145
+ return typeMap[ext] || 'unknown';
146
+ }
147
+
148
+ /**
149
+ * Extract exports from JavaScript/TypeScript file content
150
+ * @param {string} content - File content
151
+ * @returns {string[]} List of export names
152
+ */
153
+ function extractExports(content) {
154
+ const exports = [];
155
+
156
+ // Named exports: export const/let/var/function/class name
157
+ const namedExportRegex = /export\s+(?:const|let|var|function|class|async\s+function)\s+(\w+)/g;
158
+ let match;
159
+ while ((match = namedExportRegex.exec(content)) !== null) {
160
+ exports.push(match[1]);
161
+ }
162
+
163
+ // Export { name1, name2 }
164
+ const bracketExportRegex = /export\s*\{([^}]+)\}/g;
165
+ while ((match = bracketExportRegex.exec(content)) !== null) {
166
+ const names = match[1].split(',').map(n => {
167
+ const parts = n.trim().split(/\s+as\s+/);
168
+ return parts[parts.length - 1].trim();
169
+ });
170
+ exports.push(...names.filter(n => n && n !== 'default'));
171
+ }
172
+
173
+ // module.exports = { name1, name2 } or module.exports.name
174
+ const cjsExportRegex = /module\.exports(?:\.(\w+))?\s*=/g;
175
+ while ((match = cjsExportRegex.exec(content)) !== null) {
176
+ if (match[1]) {
177
+ exports.push(match[1]);
178
+ }
179
+ }
180
+
181
+ // module.exports = { ... } - extract object keys (handles shorthand: { foo, bar })
182
+ const cjsObjectRegex = /module\.exports\s*=\s*\{([^}]+)\}/;
183
+ const cjsMatch = content.match(cjsObjectRegex);
184
+ if (cjsMatch) {
185
+ // Split by comma and extract property names (handles "foo," "bar:" "baz")
186
+ const props = cjsMatch[1].split(',');
187
+ for (const prop of props) {
188
+ const trimmed = prop.trim();
189
+ // Extract the key name (before : or the whole thing for shorthand)
190
+ const keyMatch = trimmed.match(/^(\w+)/);
191
+ if (keyMatch) {
192
+ exports.push(keyMatch[1]);
193
+ }
194
+ }
195
+ }
196
+
197
+ return [...new Set(exports)]; // Dedupe
198
+ }
199
+
200
+ /**
201
+ * Extract imports from JavaScript/TypeScript file content
202
+ * @param {string} content - File content
203
+ * @returns {string[]} List of import sources
204
+ */
205
+ function extractImports(content) {
206
+ const imports = [];
207
+
208
+ // ES6 imports: import ... from 'source'
209
+ const es6ImportRegex = /import\s+(?:[\w{},\s*]+\s+from\s+)?['"]([^'"]+)['"]/g;
210
+ let match;
211
+ while ((match = es6ImportRegex.exec(content)) !== null) {
212
+ imports.push(match[1]);
213
+ }
214
+
215
+ // CommonJS requires: require('source')
216
+ const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
217
+ while ((match = requireRegex.exec(content)) !== null) {
218
+ imports.push(match[1]);
219
+ }
220
+
221
+ return [...new Set(imports)]; // Dedupe
222
+ }
223
+
224
+ /**
225
+ * Extract function and class names from content
226
+ * @param {string} content - File content
227
+ * @returns {Object} { functions: string[], classes: string[] }
228
+ */
229
+ function extractSymbols(content) {
230
+ const functions = [];
231
+ const classes = [];
232
+
233
+ // Function declarations: function name() or async function name()
234
+ const funcRegex = /(?:async\s+)?function\s+(\w+)/g;
235
+ let match;
236
+ while ((match = funcRegex.exec(content)) !== null) {
237
+ functions.push(match[1]);
238
+ }
239
+
240
+ // Arrow functions assigned to const: const name = () =>
241
+ const arrowRegex = /(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\(/g;
242
+ while ((match = arrowRegex.exec(content)) !== null) {
243
+ functions.push(match[1]);
244
+ }
245
+
246
+ // Class declarations: class Name
247
+ const classRegex = /class\s+(\w+)/g;
248
+ while ((match = classRegex.exec(content)) !== null) {
249
+ classes.push(match[1]);
250
+ }
251
+
252
+ return {
253
+ functions: [...new Set(functions)],
254
+ classes: [...new Set(classes)],
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Detect tags for a file based on path and content
260
+ * @param {string} filePath - File path relative to project root
261
+ * @param {string} content - File content
262
+ * @returns {string[]} List of tags
263
+ */
264
+ function detectTags(filePath, content) {
265
+ const tags = [];
266
+
267
+ // Path-based tags
268
+ for (const [tag, pattern] of Object.entries(TAG_PATTERNS)) {
269
+ if (pattern.test(filePath)) {
270
+ tags.push(tag);
271
+ }
272
+ }
273
+
274
+ // Content-based tags (look for common patterns)
275
+ if (/\bexpress\b|\brouter\b|\bapp\.(get|post|put|delete)\b/i.test(content)) {
276
+ if (!tags.includes('api')) tags.push('api');
277
+ }
278
+ if (/\bReact\b|\buseState\b|\buseEffect\b|\bcomponent\b/i.test(content)) {
279
+ if (!tags.includes('ui')) tags.push('ui');
280
+ }
281
+ if (/\bsequelize\b|\bprisma\b|\bmongodb\b|\bsql\b/i.test(content)) {
282
+ if (!tags.includes('database')) tags.push('database');
283
+ }
284
+ if (/\bjwt\b|\bpassport\b|\bauthenticate\b|\blogin\b/i.test(content)) {
285
+ if (!tags.includes('auth')) tags.push('auth');
286
+ }
287
+
288
+ return [...new Set(tags)];
289
+ }
290
+
291
+ /**
292
+ * Check if file should be included based on patterns
293
+ * @param {string} relativePath - Path relative to project root
294
+ * @param {string[]} excludePatterns - Patterns to exclude
295
+ * @returns {boolean} True if file should be included
296
+ */
297
+ function shouldIncludeFile(relativePath, excludePatterns) {
298
+ for (const pattern of excludePatterns) {
299
+ // Convert glob to regex
300
+ // Handle ** (matches any path segments including none)
301
+ // Handle * (matches within a single path segment)
302
+ let regexPattern = pattern
303
+ .replace(/\./g, '\\.') // Escape dots
304
+ .replace(/\*\*/g, '<<<GLOB>>>') // Temp placeholder for **
305
+ .replace(/\*/g, '[^/]*') // Single * = any chars except /
306
+ .replace(/<<<GLOB>>>/g, '.*') // ** = any chars including /
307
+ .replace(/\?/g, '.'); // ? = any single char
308
+
309
+ // Support patterns that should match the start of path
310
+ const regex = new RegExp(`^${regexPattern}`);
311
+ if (regex.test(relativePath)) {
312
+ return false;
313
+ }
314
+ }
315
+ return true;
316
+ }
317
+
318
+ /**
319
+ * Recursively scan directory for files
320
+ * @param {string} dirPath - Directory to scan
321
+ * @param {string} projectRoot - Project root for relative paths
322
+ * @param {string[]} excludePatterns - Patterns to exclude
323
+ * @param {number} maxFileSizeKb - Max file size in KB
324
+ * @returns {Object[]} List of file info objects
325
+ */
326
+ function scanDirectory(dirPath, projectRoot, excludePatterns, maxFileSizeKb) {
327
+ const files = [];
328
+
329
+ try {
330
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
331
+
332
+ for (const entry of entries) {
333
+ const fullPath = path.join(dirPath, entry.name);
334
+ const relativePath = path.relative(projectRoot, fullPath);
335
+
336
+ // Check exclusion
337
+ if (!shouldIncludeFile(relativePath, excludePatterns)) {
338
+ continue;
339
+ }
340
+
341
+ if (entry.isDirectory()) {
342
+ // Recurse into subdirectory
343
+ files.push(...scanDirectory(fullPath, projectRoot, excludePatterns, maxFileSizeKb));
344
+ } else if (entry.isFile()) {
345
+ try {
346
+ const stat = fs.statSync(fullPath);
347
+ const sizeKb = stat.size / 1024;
348
+
349
+ // Skip files that are too large
350
+ if (sizeKb > maxFileSizeKb) {
351
+ if (DEBUG) debugLog(`Skipping large file: ${relativePath} (${sizeKb.toFixed(1)}KB)`);
352
+ continue;
353
+ }
354
+
355
+ files.push({
356
+ path: relativePath,
357
+ fullPath,
358
+ size: stat.size,
359
+ mtime: stat.mtime.getTime(),
360
+ });
361
+ } catch (err) {
362
+ if (DEBUG) debugLog(`Error stat file ${relativePath}: ${err.message}`);
363
+ }
364
+ }
365
+ }
366
+ } catch (err) {
367
+ if (DEBUG) debugLog(`Error scanning directory ${dirPath}: ${err.message}`);
368
+ }
369
+
370
+ return files;
371
+ }
372
+
373
+ /**
374
+ * Build complete codebase index
375
+ * @param {string} projectRoot - Project root directory
376
+ * @param {Object} options - Configuration options
377
+ * @returns {Object} { ok: boolean, data?: Object, error?: string }
378
+ */
379
+ function buildIndex(projectRoot, options = {}) {
380
+ const startTime = Date.now();
381
+
382
+ // Load config from metadata, then override with options
383
+ const baseConfig = loadConfig(projectRoot);
384
+ const config = {
385
+ ...baseConfig,
386
+ ...options,
387
+ };
388
+
389
+ try {
390
+ // Verify project root exists
391
+ if (!fs.existsSync(projectRoot)) {
392
+ return { ok: false, error: `Project root not found: ${projectRoot}` };
393
+ }
394
+
395
+ const index = createEmptyIndex(projectRoot);
396
+
397
+ // Scan for files
398
+ const files = scanDirectory(
399
+ projectRoot,
400
+ projectRoot,
401
+ config.excludePatterns,
402
+ config.maxFileSizeKb
403
+ );
404
+ index.stats.total_files = files.length;
405
+
406
+ // Process each file
407
+ for (const fileInfo of files) {
408
+ const { path: relativePath, fullPath, size, mtime } = fileInfo;
409
+ const type = getFileType(relativePath);
410
+
411
+ // Read content for code files
412
+ let content = '';
413
+ let exports = [];
414
+ let imports = [];
415
+ let symbols = { functions: [], classes: [] };
416
+ let tags = [];
417
+
418
+ if (['javascript', 'typescript', 'javascript-react', 'typescript-react'].includes(type)) {
419
+ try {
420
+ content = fs.readFileSync(fullPath, 'utf8');
421
+ exports = extractExports(content);
422
+ imports = extractImports(content);
423
+ symbols = extractSymbols(content);
424
+ tags = detectTags(relativePath, content);
425
+ } catch (err) {
426
+ if (DEBUG) debugLog(`Error reading ${relativePath}: ${err.message}`);
427
+ }
428
+ } else {
429
+ // Just detect tags from path for non-code files
430
+ tags = detectTags(relativePath, '');
431
+ }
432
+
433
+ // Add file to index
434
+ index.files[relativePath] = {
435
+ type,
436
+ size,
437
+ mtime,
438
+ exports,
439
+ imports,
440
+ tags,
441
+ };
442
+
443
+ // Update tag index (use Object.hasOwn to avoid prototype pollution)
444
+ for (const tag of tags) {
445
+ if (!Object.hasOwn(index.tags, tag)) {
446
+ index.tags[tag] = [];
447
+ }
448
+ index.tags[tag].push(relativePath);
449
+ }
450
+
451
+ // Update symbol index (use Object.hasOwn to avoid prototype pollution with names like "constructor")
452
+ for (const func of symbols.functions) {
453
+ if (!Object.hasOwn(index.symbols.functions, func)) {
454
+ index.symbols.functions[func] = [];
455
+ }
456
+ index.symbols.functions[func].push(relativePath);
457
+ }
458
+ for (const cls of symbols.classes) {
459
+ if (!Object.hasOwn(index.symbols.classes, cls)) {
460
+ index.symbols.classes[cls] = [];
461
+ }
462
+ index.symbols.classes[cls].push(relativePath);
463
+ }
464
+ for (const exp of exports) {
465
+ if (!Object.hasOwn(index.symbols.exports, exp)) {
466
+ index.symbols.exports[exp] = [];
467
+ }
468
+ index.symbols.exports[exp].push(relativePath);
469
+ }
470
+
471
+ index.stats.indexed_files++;
472
+ }
473
+
474
+ // Update timing
475
+ index.stats.build_time_ms = Date.now() - startTime;
476
+ index.updated_at = new Date().toISOString();
477
+
478
+ // Store in cache
479
+ const cacheKey = `index:${projectRoot}`;
480
+ indexCache.set(cacheKey, index);
481
+
482
+ // Persist to disk
483
+ const cachePath = getCachePath(projectRoot);
484
+ const writeResult = saveIndexToDisk(cachePath, index);
485
+ if (!writeResult.ok && DEBUG) {
486
+ debugLog(`Warning: Could not persist index to disk: ${writeResult.error}`);
487
+ }
488
+
489
+ return { ok: true, data: index };
490
+ } catch (err) {
491
+ return { ok: false, error: err.message };
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Get cache file path for a project
497
+ * @param {string} projectRoot - Project root
498
+ * @returns {string} Cache file path
499
+ */
500
+ function getCachePath(projectRoot) {
501
+ return path.join(projectRoot, '.agileflow', 'cache', 'codebase-index.json');
502
+ }
503
+
504
+ /**
505
+ * Save index to disk atomically
506
+ * @param {string} cachePath - Path to save to
507
+ * @param {Object} index - Index data
508
+ * @returns {Object} { ok: boolean, error?: string }
509
+ */
510
+ function saveIndexToDisk(cachePath, index) {
511
+ try {
512
+ const dir = path.dirname(cachePath);
513
+ if (!fs.existsSync(dir)) {
514
+ fs.mkdirSync(dir, { recursive: true });
515
+ }
516
+
517
+ // Atomic write via temp file
518
+ const tempPath = `${cachePath}.tmp`;
519
+ fs.writeFileSync(tempPath, JSON.stringify(index, null, 2));
520
+ fs.renameSync(tempPath, cachePath);
521
+
522
+ return { ok: true };
523
+ } catch (err) {
524
+ return { ok: false, error: err.message };
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Load index from disk
530
+ * @param {string} cachePath - Cache file path
531
+ * @returns {Object} { ok: boolean, data?: Object, error?: string }
532
+ */
533
+ function loadIndexFromDisk(cachePath) {
534
+ return safeReadJSON(cachePath);
535
+ }
536
+
537
+ /**
538
+ * Update index incrementally (only changed files)
539
+ * @param {string} projectRoot - Project root
540
+ * @param {Object} options - Configuration options
541
+ * @returns {Object} { ok: boolean, data?: Object, error?: string }
542
+ */
543
+ function updateIndex(projectRoot, options = {}) {
544
+ const config = {
545
+ ...DEFAULT_CONFIG,
546
+ ...options,
547
+ };
548
+
549
+ try {
550
+ // Try to load existing index
551
+ const cachePath = getCachePath(projectRoot);
552
+ const loadResult = loadIndexFromDisk(cachePath);
553
+
554
+ // If no existing index, do full build
555
+ if (!loadResult.ok) {
556
+ return buildIndex(projectRoot, options);
557
+ }
558
+
559
+ const existingIndex = loadResult.data;
560
+
561
+ // Check if version matches
562
+ if (existingIndex.version !== INDEX_VERSION) {
563
+ if (DEBUG) debugLog('Index version mismatch, rebuilding');
564
+ return buildIndex(projectRoot, options);
565
+ }
566
+
567
+ const startTime = Date.now();
568
+
569
+ // Scan for current files
570
+ const currentFiles = scanDirectory(
571
+ projectRoot,
572
+ projectRoot,
573
+ config.excludePatterns,
574
+ config.maxFileSizeKb
575
+ );
576
+ const currentFilePaths = new Set(currentFiles.map(f => f.path));
577
+
578
+ // Track changes
579
+ let changedCount = 0;
580
+ let addedCount = 0;
581
+ let removedCount = 0;
582
+
583
+ // Remove deleted files from index
584
+ for (const filePath of Object.keys(existingIndex.files)) {
585
+ if (!currentFilePaths.has(filePath)) {
586
+ delete existingIndex.files[filePath];
587
+ removedCount++;
588
+ }
589
+ }
590
+
591
+ // Check for new or modified files
592
+ for (const fileInfo of currentFiles) {
593
+ const { path: relativePath, fullPath, size, mtime } = fileInfo;
594
+ const existing = existingIndex.files[relativePath];
595
+
596
+ // If new file or modified (mtime changed)
597
+ if (!existing || existing.mtime !== mtime) {
598
+ const type = getFileType(relativePath);
599
+
600
+ let exports = [];
601
+ let imports = [];
602
+ let symbols = { functions: [], classes: [] };
603
+ let tags = [];
604
+
605
+ if (['javascript', 'typescript', 'javascript-react', 'typescript-react'].includes(type)) {
606
+ try {
607
+ const content = fs.readFileSync(fullPath, 'utf8');
608
+ exports = extractExports(content);
609
+ imports = extractImports(content);
610
+ symbols = extractSymbols(content);
611
+ tags = detectTags(relativePath, content);
612
+ } catch (err) {
613
+ if (DEBUG) debugLog(`Error reading ${relativePath}: ${err.message}`);
614
+ }
615
+ } else {
616
+ tags = detectTags(relativePath, '');
617
+ }
618
+
619
+ existingIndex.files[relativePath] = {
620
+ type,
621
+ size,
622
+ mtime,
623
+ exports,
624
+ imports,
625
+ tags,
626
+ };
627
+
628
+ if (existing) {
629
+ changedCount++;
630
+ } else {
631
+ addedCount++;
632
+ }
633
+ }
634
+ }
635
+
636
+ // Rebuild tag and symbol indices
637
+ existingIndex.tags = {};
638
+ existingIndex.symbols = { functions: {}, classes: {}, exports: {} };
639
+
640
+ for (const [filePath, fileData] of Object.entries(existingIndex.files)) {
641
+ for (const tag of fileData.tags || []) {
642
+ if (!Object.hasOwn(existingIndex.tags, tag)) existingIndex.tags[tag] = [];
643
+ existingIndex.tags[tag].push(filePath);
644
+ }
645
+ for (const exp of fileData.exports || []) {
646
+ if (!Object.hasOwn(existingIndex.symbols.exports, exp)) existingIndex.symbols.exports[exp] = [];
647
+ existingIndex.symbols.exports[exp].push(filePath);
648
+ }
649
+ }
650
+
651
+ // Update stats
652
+ existingIndex.stats.total_files = currentFiles.length;
653
+ existingIndex.stats.indexed_files = Object.keys(existingIndex.files).length;
654
+ existingIndex.stats.build_time_ms = Date.now() - startTime;
655
+ existingIndex.updated_at = new Date().toISOString();
656
+
657
+ // Store in cache
658
+ const cacheKey = `index:${projectRoot}`;
659
+ indexCache.set(cacheKey, existingIndex);
660
+
661
+ // Persist to disk
662
+ saveIndexToDisk(cachePath, existingIndex);
663
+
664
+ if (DEBUG) {
665
+ debugLog(`Index updated: +${addedCount} -${removedCount} ~${changedCount}`);
666
+ }
667
+
668
+ return { ok: true, data: existingIndex };
669
+ } catch (err) {
670
+ return { ok: false, error: err.message };
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Get index (from cache or disk, or build if needed)
676
+ * @param {string} projectRoot - Project root
677
+ * @param {Object} options - Configuration options
678
+ * @returns {Object} { ok: boolean, data?: Object, error?: string }
679
+ */
680
+ function getIndex(projectRoot, options = {}) {
681
+ // Check memory cache first
682
+ const cacheKey = `index:${projectRoot}`;
683
+ const cached = indexCache.get(cacheKey);
684
+ if (cached) {
685
+ return { ok: true, data: cached };
686
+ }
687
+
688
+ // Try disk cache
689
+ const cachePath = getCachePath(projectRoot);
690
+ const diskResult = loadIndexFromDisk(cachePath);
691
+ if (diskResult.ok) {
692
+ // Validate version
693
+ if (diskResult.data.version === INDEX_VERSION) {
694
+ // Store in memory cache
695
+ indexCache.set(cacheKey, diskResult.data);
696
+ return { ok: true, data: diskResult.data };
697
+ }
698
+ }
699
+
700
+ // Build fresh index
701
+ return buildIndex(projectRoot, options);
702
+ }
703
+
704
+ /**
705
+ * Invalidate cached index
706
+ * @param {string} projectRoot - Project root
707
+ */
708
+ function invalidateIndex(projectRoot) {
709
+ const cacheKey = `index:${projectRoot}`;
710
+ indexCache.delete(cacheKey);
711
+ }
712
+
713
+ /**
714
+ * Query files by glob pattern
715
+ * @param {Object} index - Codebase index
716
+ * @param {string} pattern - Glob pattern (e.g., "*.auth*", "src/api/**")
717
+ * @returns {string[]} Matching file paths
718
+ */
719
+ function queryFiles(index, pattern) {
720
+ // Convert glob to regex
721
+ // Order matters: handle ** before * to avoid double processing
722
+ let regexPattern = pattern
723
+ // First, use placeholders to protect multi-char patterns
724
+ .replace(/\*\*\//g, '<<<GLOBSLASH>>>') // **/ placeholder
725
+ .replace(/\*\*/g, '<<<GLOB>>>') // ** placeholder
726
+ .replace(/\./g, '\\.') // Escape dots
727
+ .replace(/\?/g, '.') // ? = any single char
728
+ .replace(/\*/g, '[^/]*') // Single * = any chars except /
729
+ // Now restore placeholders with actual patterns
730
+ .replace(/<<<GLOBSLASH>>>/g, '(?:.+/)?') // **/ = optionally any path + /
731
+ .replace(/<<<GLOB>>>/g, '.*'); // ** alone = any chars including /
732
+
733
+ const regex = new RegExp(`^${regexPattern}$`, 'i');
734
+
735
+ return Object.keys(index.files).filter(filePath => regex.test(filePath));
736
+ }
737
+
738
+ /**
739
+ * Query files by tag
740
+ * @param {Object} index - Codebase index
741
+ * @param {string} tag - Tag to search for
742
+ * @returns {string[]} Files with this tag
743
+ */
744
+ function queryByTag(index, tag) {
745
+ return index.tags[tag.toLowerCase()] || [];
746
+ }
747
+
748
+ /**
749
+ * Query files by exported symbol
750
+ * @param {Object} index - Codebase index
751
+ * @param {string} symbolName - Symbol name to find
752
+ * @returns {string[]} Files exporting this symbol
753
+ */
754
+ function queryByExport(index, symbolName) {
755
+ return index.symbols.exports[symbolName] || [];
756
+ }
757
+
758
+ /**
759
+ * Get dependencies of a file
760
+ * @param {Object} index - Codebase index
761
+ * @param {string} filePath - File path
762
+ * @returns {Object} { imports: string[], importedBy: string[] }
763
+ */
764
+ function getDependencies(index, filePath) {
765
+ const fileData = index.files[filePath];
766
+ if (!fileData) {
767
+ return { imports: [], importedBy: [] };
768
+ }
769
+
770
+ const imports = fileData.imports || [];
771
+
772
+ // Find files that import this file
773
+ const importedBy = [];
774
+ const baseName = path.basename(filePath).replace(/\.\w+$/, '');
775
+
776
+ for (const [otherPath, otherData] of Object.entries(index.files)) {
777
+ if (otherPath === filePath) continue;
778
+ const otherImports = otherData.imports || [];
779
+ for (const imp of otherImports) {
780
+ if (imp.includes(baseName) || imp.includes(filePath)) {
781
+ importedBy.push(otherPath);
782
+ break;
783
+ }
784
+ }
785
+ }
786
+
787
+ return { imports, importedBy };
788
+ }
789
+
790
+ module.exports = {
791
+ // Core functions
792
+ buildIndex,
793
+ updateIndex,
794
+ getIndex,
795
+ invalidateIndex,
796
+
797
+ // Query functions
798
+ queryFiles,
799
+ queryByTag,
800
+ queryByExport,
801
+ getDependencies,
802
+
803
+ // Configuration
804
+ loadConfig,
805
+
806
+ // Utilities (exposed for testing)
807
+ extractExports,
808
+ extractImports,
809
+ extractSymbols,
810
+ detectTags,
811
+ shouldIncludeFile,
812
+ getFileType,
813
+
814
+ // Constants
815
+ INDEX_VERSION,
816
+ DEFAULT_CONFIG,
817
+ TAG_PATTERNS,
818
+ };