@reinteractive/rails-insight 1.0.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 (90) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +210 -0
  3. package/bin/railsinsight.js +128 -0
  4. package/package.json +62 -0
  5. package/src/core/blast-radius.js +496 -0
  6. package/src/core/constants.js +39 -0
  7. package/src/core/context-loader.js +227 -0
  8. package/src/core/drift-detector.js +168 -0
  9. package/src/core/formatter.js +197 -0
  10. package/src/core/graph.js +510 -0
  11. package/src/core/indexer.js +595 -0
  12. package/src/core/patterns/api.js +27 -0
  13. package/src/core/patterns/auth.js +25 -0
  14. package/src/core/patterns/authorization.js +24 -0
  15. package/src/core/patterns/caching.js +19 -0
  16. package/src/core/patterns/component.js +18 -0
  17. package/src/core/patterns/config.js +15 -0
  18. package/src/core/patterns/controller.js +42 -0
  19. package/src/core/patterns/email.js +20 -0
  20. package/src/core/patterns/factory.js +31 -0
  21. package/src/core/patterns/gemfile.js +9 -0
  22. package/src/core/patterns/helper.js +10 -0
  23. package/src/core/patterns/job.js +12 -0
  24. package/src/core/patterns/model.js +123 -0
  25. package/src/core/patterns/realtime.js +17 -0
  26. package/src/core/patterns/route.js +27 -0
  27. package/src/core/patterns/schema.js +25 -0
  28. package/src/core/patterns/stimulus.js +13 -0
  29. package/src/core/patterns/storage.js +16 -0
  30. package/src/core/patterns/uploader.js +16 -0
  31. package/src/core/patterns/view.js +20 -0
  32. package/src/core/patterns/worker.js +12 -0
  33. package/src/core/patterns.js +27 -0
  34. package/src/core/scanner.js +394 -0
  35. package/src/core/version-detector.js +295 -0
  36. package/src/extractors/api.js +284 -0
  37. package/src/extractors/auth.js +853 -0
  38. package/src/extractors/authorization.js +785 -0
  39. package/src/extractors/caching.js +84 -0
  40. package/src/extractors/component.js +221 -0
  41. package/src/extractors/config.js +81 -0
  42. package/src/extractors/controller.js +273 -0
  43. package/src/extractors/coverage-snapshot.js +296 -0
  44. package/src/extractors/email.js +123 -0
  45. package/src/extractors/factory-registry.js +225 -0
  46. package/src/extractors/gemfile.js +440 -0
  47. package/src/extractors/helper.js +55 -0
  48. package/src/extractors/jobs.js +122 -0
  49. package/src/extractors/model.js +506 -0
  50. package/src/extractors/realtime.js +102 -0
  51. package/src/extractors/routes.js +251 -0
  52. package/src/extractors/schema.js +178 -0
  53. package/src/extractors/stimulus.js +149 -0
  54. package/src/extractors/storage.js +100 -0
  55. package/src/extractors/test-conventions.js +340 -0
  56. package/src/extractors/tier2.js +417 -0
  57. package/src/extractors/tier3.js +84 -0
  58. package/src/extractors/uploader.js +138 -0
  59. package/src/extractors/views.js +131 -0
  60. package/src/extractors/worker.js +62 -0
  61. package/src/git/diff-parser.js +132 -0
  62. package/src/providers/interface.js +12 -0
  63. package/src/providers/local-fs.js +318 -0
  64. package/src/server.js +71 -0
  65. package/src/tools/blast-radius-tools.js +129 -0
  66. package/src/tools/free-tools.js +44 -0
  67. package/src/tools/handlers/get-controller.js +93 -0
  68. package/src/tools/handlers/get-coverage-gaps.js +100 -0
  69. package/src/tools/handlers/get-deep-analysis.js +294 -0
  70. package/src/tools/handlers/get-domain-clusters.js +113 -0
  71. package/src/tools/handlers/get-factory-registry.js +43 -0
  72. package/src/tools/handlers/get-full-index.js +28 -0
  73. package/src/tools/handlers/get-model.js +108 -0
  74. package/src/tools/handlers/get-overview.js +153 -0
  75. package/src/tools/handlers/get-routes.js +18 -0
  76. package/src/tools/handlers/get-schema.js +40 -0
  77. package/src/tools/handlers/get-subgraph.js +82 -0
  78. package/src/tools/handlers/get-test-conventions.js +18 -0
  79. package/src/tools/handlers/get-well-tested-examples.js +51 -0
  80. package/src/tools/handlers/helpers.js +115 -0
  81. package/src/tools/handlers/index-project.js +36 -0
  82. package/src/tools/handlers/search-patterns.js +104 -0
  83. package/src/tools/index.js +34 -0
  84. package/src/tools/pro-tools.js +13 -0
  85. package/src/utils/file-reader.js +20 -0
  86. package/src/utils/inflector.js +223 -0
  87. package/src/utils/ruby-parser.js +115 -0
  88. package/src/utils/spec-style-detector.js +26 -0
  89. package/src/utils/token-counter.js +46 -0
  90. package/src/utils/yaml-parser.js +135 -0
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Views Extractor (#7)
3
+ * Lightweight aggregated scan of app/views for structural indicators.
4
+ */
5
+
6
+ import { VIEW_PATTERNS } from '../core/patterns.js'
7
+
8
+ /**
9
+ * Detect primary template engine from file extensions.
10
+ * @param {Array<{path: string}>} entries
11
+ * @returns {string}
12
+ */
13
+ function detectEngine(entries) {
14
+ let erb = 0,
15
+ haml = 0,
16
+ slim = 0
17
+ for (const e of entries) {
18
+ if (e.path.endsWith('.erb')) erb++
19
+ else if (e.path.endsWith('.haml')) haml++
20
+ else if (e.path.endsWith('.slim')) slim++
21
+ }
22
+ if (haml > erb && haml > slim) return 'haml'
23
+ if (slim > erb && slim > haml) return 'slim'
24
+ return 'erb'
25
+ }
26
+
27
+ /**
28
+ * Extract aggregated view layer information.
29
+ * @param {import('../providers/interface.js').FileProvider} provider
30
+ * @param {Array<{path: string, category: string}>} entries - all scanned entries
31
+ * @returns {object}
32
+ */
33
+ export function extractViews(provider, entries) {
34
+ const result = {
35
+ layouts: [],
36
+ template_engine: 'erb',
37
+ turbo_frames_count: 0,
38
+ turbo_stream_templates: 0,
39
+ component_renders: 0,
40
+ partial_renders: 0,
41
+ form_with_usage: 0,
42
+ form_for_usage: 0,
43
+ jbuilder_views: 0,
44
+ content_for_keys: [],
45
+ }
46
+
47
+ const viewEntries = entries.filter(
48
+ (e) =>
49
+ e.path.startsWith('app/views/') ||
50
+ e.category === 'view' ||
51
+ e.category === 'layout' ||
52
+ e.category === 'partial' ||
53
+ e.category === 'jbuilder',
54
+ )
55
+
56
+ if (viewEntries.length === 0) return result
57
+
58
+ result.template_engine = detectEngine(viewEntries)
59
+
60
+ const contentForKeys = new Set()
61
+ let jbuilderCount = 0
62
+
63
+ for (const entry of viewEntries) {
64
+ const { path } = entry
65
+
66
+ // Layouts
67
+ if (path.startsWith('app/views/layouts/')) {
68
+ const name = path
69
+ .replace('app/views/layouts/', '')
70
+ .replace(/\.\w+(\.\w+)*$/, '')
71
+ if (!result.layouts.includes(name)) {
72
+ result.layouts.push(name)
73
+ }
74
+ }
75
+
76
+ // Turbo stream templates
77
+ if (path.includes('.turbo_stream.')) {
78
+ result.turbo_stream_templates++
79
+ }
80
+
81
+ // Jbuilder
82
+ if (path.endsWith('.jbuilder')) {
83
+ jbuilderCount++
84
+ }
85
+
86
+ // Read content for pattern matching
87
+ const content = provider.readFile(path)
88
+ if (!content) continue
89
+
90
+ // Turbo frames
91
+ const frameRe = new RegExp(VIEW_PATTERNS.turboFrame.source, 'g')
92
+ let m
93
+ while ((m = frameRe.exec(content))) {
94
+ result.turbo_frames_count++
95
+ }
96
+
97
+ // Component renders
98
+ const compRe = new RegExp(VIEW_PATTERNS.componentRender.source, 'g')
99
+ while ((m = compRe.exec(content))) {
100
+ result.component_renders++
101
+ }
102
+
103
+ // Partial renders
104
+ const partialRe = new RegExp(VIEW_PATTERNS.partialRender.source, 'g')
105
+ while ((m = partialRe.exec(content))) {
106
+ result.partial_renders++
107
+ }
108
+
109
+ // Form helpers
110
+ const formWithRe = new RegExp(VIEW_PATTERNS.formWith.source, 'g')
111
+ while ((m = formWithRe.exec(content))) {
112
+ result.form_with_usage++
113
+ }
114
+
115
+ const formForRe = new RegExp(VIEW_PATTERNS.formFor.source, 'g')
116
+ while ((m = formForRe.exec(content))) {
117
+ result.form_for_usage++
118
+ }
119
+
120
+ // Content for keys
121
+ const cfRe = new RegExp(VIEW_PATTERNS.contentFor.source, 'g')
122
+ while ((m = cfRe.exec(content))) {
123
+ contentForKeys.add(m[1])
124
+ }
125
+ }
126
+
127
+ result.jbuilder_views = jbuilderCount
128
+ result.content_for_keys = [...contentForKeys].sort()
129
+
130
+ return result
131
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Worker Extractor (#10 — Jobs sub-type)
3
+ * Extracts Sidekiq native worker metadata: class name, queue, retry config,
4
+ * sidekiq_options, and perform method signature.
5
+ */
6
+
7
+ import { WORKER_PATTERNS } from '../core/patterns.js'
8
+
9
+ /**
10
+ * Extract a single Sidekiq native worker's metadata.
11
+ * @param {import('../providers/interface.js').FileProvider} provider
12
+ * @param {string} filePath
13
+ * @returns {object|null}
14
+ */
15
+ export function extractWorker(provider, filePath) {
16
+ const content = provider.readFile(filePath)
17
+ if (!content) return null
18
+
19
+ // Must include Sidekiq::Job or Sidekiq::Worker
20
+ const includeMatch = content.match(WORKER_PATTERNS.includeSidekiq)
21
+ if (!includeMatch) return null
22
+
23
+ const classMatch = content.match(WORKER_PATTERNS.classDeclaration)
24
+ if (!classMatch) return null
25
+
26
+ const result = {
27
+ class: classMatch[1],
28
+ file: filePath,
29
+ type: 'sidekiq_native',
30
+ queue: 'default',
31
+ retry: true,
32
+ sidekiq_options: null,
33
+ perform_args: [],
34
+ }
35
+
36
+ // Sidekiq options
37
+ const optionsMatch = content.match(WORKER_PATTERNS.sidekiqOptions)
38
+ if (optionsMatch) {
39
+ result.sidekiq_options = optionsMatch[1].trim()
40
+
41
+ // Extract queue
42
+ const queueMatch = optionsMatch[1].match(WORKER_PATTERNS.queueOption)
43
+ if (queueMatch) {
44
+ result.queue = queueMatch[1]
45
+ }
46
+
47
+ // Extract retry
48
+ const retryMatch = optionsMatch[1].match(WORKER_PATTERNS.retryOption)
49
+ if (retryMatch) {
50
+ result.retry =
51
+ retryMatch[1] === 'false' ? false : parseInt(retryMatch[1], 10)
52
+ }
53
+ }
54
+
55
+ // Perform arguments
56
+ const performMatch = content.match(WORKER_PATTERNS.performSignature)
57
+ if (performMatch && performMatch[1].trim()) {
58
+ result.perform_args = performMatch[1].split(',').map((a) => a.trim())
59
+ }
60
+
61
+ return result
62
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Git diff detection and file parsing.
3
+ * Detects changed files via git commands or parses raw diff output.
4
+ */
5
+
6
+ const STATUS_MAP = {
7
+ M: 'modified',
8
+ A: 'added',
9
+ D: 'deleted',
10
+ R: 'renamed',
11
+ C: 'copied',
12
+ T: 'type-changed',
13
+ }
14
+
15
+ /**
16
+ * Parse a raw git diff --name-status output string into structured data.
17
+ * @param {string} rawOutput
18
+ * @returns {Array<{path: string, status: string}>}
19
+ */
20
+ export function parseDiffOutput(rawOutput) {
21
+ if (!rawOutput || !rawOutput.trim()) return []
22
+
23
+ return rawOutput
24
+ .split('\n')
25
+ .filter((line) => line.trim())
26
+ .map((line) => parseDiffLine(line))
27
+ .filter(Boolean)
28
+ }
29
+
30
+ /**
31
+ * Parse a single diff line.
32
+ * @param {string} line
33
+ * @returns {{path: string, status: string, oldPath?: string}|null}
34
+ */
35
+ function parseDiffLine(line) {
36
+ const parts = line.split('\t')
37
+ if (parts.length < 2) return null
38
+
39
+ const statusCode = parts[0].charAt(0)
40
+ const status = STATUS_MAP[statusCode] || 'unknown'
41
+
42
+ if (statusCode === 'R' || statusCode === 'C') {
43
+ return { path: parts[2] || parts[1], status, oldPath: parts[1] }
44
+ }
45
+ return { path: parts[1], status }
46
+ }
47
+
48
+ /**
49
+ * Parse untracked file listing into structured data.
50
+ * @param {string} rawOutput
51
+ * @returns {Array<{path: string, status: string}>}
52
+ */
53
+ function parseUntrackedOutput(rawOutput) {
54
+ if (!rawOutput || !rawOutput.trim()) return []
55
+ return rawOutput
56
+ .split('\n')
57
+ .filter((line) => line.trim())
58
+ .map((path) => ({ path: path.trim(), status: 'added' }))
59
+ }
60
+
61
+ /**
62
+ * Validate that a git ref is safe for shell interpolation.
63
+ * Allows alphanumeric chars, dots, hyphens, slashes, tildes, carets, at-signs,
64
+ * braces, and colons — all legal in git refs but no shell metacharacters.
65
+ * @param {string} ref
66
+ * @returns {boolean}
67
+ */
68
+ function isValidGitRef(ref) {
69
+ return /^[\w.\-/~^@{}:]+$/.test(ref)
70
+ }
71
+
72
+ /**
73
+ * Detect changed files relative to a base ref.
74
+ * @param {import('../providers/interface.js').FileProvider} provider
75
+ * @param {string} [baseRef='HEAD'] - Git ref to diff against
76
+ * @param {Object} [options]
77
+ * @param {boolean} [options.staged] - Only staged changes (default: false)
78
+ * @param {boolean} [options.includeUntracked] - Include untracked files (default: true)
79
+ * @returns {Promise<{files: Array<{path: string, status: string}>, baseRef: string, error: string|null}>}
80
+ */
81
+ export async function detectChangedFiles(
82
+ provider,
83
+ baseRef = 'HEAD',
84
+ options = {},
85
+ ) {
86
+ const { staged = false, includeUntracked = true } = options
87
+
88
+ if (typeof provider.execCommand !== 'function') {
89
+ return {
90
+ files: [],
91
+ baseRef,
92
+ error: 'Provider does not support execCommand',
93
+ }
94
+ }
95
+
96
+ if (!staged && !isValidGitRef(baseRef)) {
97
+ return {
98
+ files: [],
99
+ baseRef,
100
+ error: 'Invalid git ref: contains unsafe characters',
101
+ }
102
+ }
103
+
104
+ const diffCommand = staged
105
+ ? 'git diff --name-status --cached'
106
+ : `git diff --name-status ${baseRef}`
107
+
108
+ const diffResult = await provider.execCommand(diffCommand)
109
+
110
+ if (diffResult.exitCode !== 0 && diffResult.stderr) {
111
+ const isNotGit =
112
+ diffResult.stderr.includes('not a git repository') ||
113
+ diffResult.stderr.includes('Not a git repository')
114
+ if (isNotGit) {
115
+ return { files: [], baseRef, error: 'Not a git repository' }
116
+ }
117
+ return { files: [], baseRef, error: diffResult.stderr.trim() }
118
+ }
119
+
120
+ const files = parseDiffOutput(diffResult.stdout)
121
+
122
+ if (includeUntracked) {
123
+ const untrackedResult = await provider.execCommand(
124
+ 'git ls-files --others --exclude-standard',
125
+ )
126
+ if (untrackedResult.exitCode === 0) {
127
+ files.push(...parseUntrackedOutput(untrackedResult.stdout))
128
+ }
129
+ }
130
+
131
+ return { files, baseRef, error: null }
132
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @typedef {Object} FileProvider
3
+ * @property {function(string): string|null} readFile - Read file contents. Returns null on error.
4
+ * @property {function(string): string[]} readLines - Read file as array of lines. Returns [] on error.
5
+ * @property {function(string): boolean} fileExists - Check if file exists.
6
+ * @property {function(string): string[]} glob - Recursive glob matching. Pattern supports ** wildcards.
7
+ * @property {function(string): string[]} listDir - List directory contents. Returns [] if not found.
8
+ * @property {function(): string} getProjectRoot - Return the project root identifier.
9
+ * @property {function(string): Promise<{stdout: string, stderr: string, exitCode: number}>} [execCommand]
10
+ * Execute a shell command in the project root. Returns stdout, stderr, and exit code.
11
+ */
12
+ export default {}
@@ -0,0 +1,318 @@
1
+ import {
2
+ readFileSync,
3
+ readdirSync,
4
+ existsSync,
5
+ statSync,
6
+ realpathSync,
7
+ } from 'node:fs'
8
+ import { join, relative, resolve, sep } from 'node:path'
9
+ import { exec } from 'node:child_process'
10
+ import { promisify } from 'node:util'
11
+ import { EXEC_MAX_BUFFER, EXEC_TIMEOUT_MS } from '../core/constants.js'
12
+
13
+ const execPromise = promisify(exec)
14
+
15
+ const SKIP_DIRS = new Set([
16
+ 'node_modules',
17
+ 'vendor',
18
+ '.git',
19
+ 'tmp',
20
+ 'log',
21
+ '.bundle',
22
+ 'coverage',
23
+ '.yarn',
24
+ ])
25
+
26
+ const SKIP_PATHS = new Set(['public/assets', 'public/packs'])
27
+
28
+ /**
29
+ * LocalFSProvider implements the FileProvider interface using Node.js fs.
30
+ * All paths are relative to projectRoot.
31
+ */
32
+ export class LocalFSProvider {
33
+ /** @param {string} projectRoot - Absolute path to the Rails project root */
34
+ constructor(projectRoot) {
35
+ this._root = projectRoot
36
+ }
37
+
38
+ /** @returns {string} */
39
+ getProjectRoot() {
40
+ return this._root
41
+ }
42
+
43
+ /**
44
+ * Resolve a relative path safely within the project root.
45
+ * Returns null if the path would escape the project root (path traversal).
46
+ * @param {string} relativePath
47
+ * @returns {string|null} Absolute path or null if unsafe
48
+ */
49
+ _safePath(relativePath) {
50
+ const full = resolve(join(this._root, relativePath))
51
+ const root = resolve(this._root)
52
+ if (full !== root && !full.startsWith(root + sep)) {
53
+ return null
54
+ }
55
+ return full
56
+ }
57
+
58
+ /**
59
+ * Read a file's contents as UTF-8.
60
+ * @param {string} relativePath
61
+ * @returns {string|null} File contents or null on error
62
+ */
63
+ readFile(relativePath) {
64
+ try {
65
+ const full = this._safePath(relativePath)
66
+ if (!full) return null
67
+ return readFileSync(full, 'utf-8')
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Read a file as an array of lines.
75
+ * @param {string} relativePath
76
+ * @returns {string[]}
77
+ */
78
+ readLines(relativePath) {
79
+ const content = this.readFile(relativePath)
80
+ if (content === null) return []
81
+ return content.split('\n')
82
+ }
83
+
84
+ /**
85
+ * Check if a file exists.
86
+ * @param {string} relativePath
87
+ * @returns {boolean}
88
+ */
89
+ fileExists(relativePath) {
90
+ try {
91
+ const full = this._safePath(relativePath)
92
+ if (!full) return false
93
+ return existsSync(full)
94
+ } catch {
95
+ return false
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Recursive glob matching. Supports ** wildcards and * single-level wildcards.
101
+ * @param {string} pattern - e.g. 'app/models/**\/*.rb'
102
+ * @returns {string[]} Matching relative paths, sorted
103
+ */
104
+ glob(pattern) {
105
+ const results = []
106
+ const parts = pattern.split('/')
107
+ const visited = new Set()
108
+ this._globWalk('', parts, results, visited)
109
+ return results.sort()
110
+ }
111
+
112
+ /**
113
+ * List directory contents.
114
+ * @param {string} relativePath
115
+ * @returns {string[]} Sorted list of entry names
116
+ */
117
+ listDir(relativePath) {
118
+ try {
119
+ const full = this._safePath(relativePath)
120
+ if (!full) return []
121
+ return readdirSync(full).sort()
122
+ } catch {
123
+ return []
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Internal recursive glob walker.
129
+ * @param {string} currentRel - Current relative directory
130
+ * @param {string[]} patternParts - Remaining pattern segments
131
+ * @param {string[]} results - Accumulator
132
+ * @param {Set<string>} visited - Visited real paths for circular symlink protection
133
+ */
134
+ _globWalk(currentRel, patternParts, results, visited) {
135
+ if (patternParts.length === 0) return
136
+
137
+ const currentAbs = join(this._root, currentRel)
138
+ const segment = patternParts[0]
139
+ const remaining = patternParts.slice(1)
140
+
141
+ if (segment === '**') {
142
+ // Match zero or more directories
143
+ // Try matching remaining pattern at current level (zero dirs)
144
+ this._globWalk(currentRel, remaining, results, visited)
145
+
146
+ // Also recurse into all subdirectories with ** still active
147
+ let entries
148
+ try {
149
+ entries = readdirSync(currentAbs, { withFileTypes: true })
150
+ } catch {
151
+ return
152
+ }
153
+
154
+ for (const entry of entries) {
155
+ if (this._shouldSkip(currentRel, entry.name)) continue
156
+ const isDir =
157
+ entry.isDirectory() ||
158
+ (entry.isSymbolicLink() &&
159
+ this._isDirectoryLink(currentRel, entry.name))
160
+ if (isDir) {
161
+ const childRel = currentRel
162
+ ? `${currentRel}/${entry.name}`
163
+ : entry.name
164
+ const childAbs = join(this._root, childRel)
165
+ const childReal = this._realPath(childAbs)
166
+ if (childReal && visited.has(childReal)) continue
167
+ if (childReal) visited.add(childReal)
168
+ this._globWalk(childRel, patternParts, results, visited)
169
+ }
170
+ }
171
+ } else if (remaining.length === 0) {
172
+ // This is the final segment — match files/dirs
173
+ let entries
174
+ try {
175
+ entries = readdirSync(currentAbs, { withFileTypes: true })
176
+ } catch {
177
+ return
178
+ }
179
+
180
+ for (const entry of entries) {
181
+ if (this._matchSegment(entry.name, segment)) {
182
+ const matchRel = currentRel
183
+ ? `${currentRel}/${entry.name}`
184
+ : entry.name
185
+ results.push(matchRel)
186
+ }
187
+ }
188
+ } else {
189
+ // Intermediate segment — match directories only
190
+ let entries
191
+ try {
192
+ entries = readdirSync(currentAbs, { withFileTypes: true })
193
+ } catch {
194
+ return
195
+ }
196
+
197
+ for (const entry of entries) {
198
+ const isDir =
199
+ entry.isDirectory() ||
200
+ (entry.isSymbolicLink() &&
201
+ this._isDirectoryLink(currentRel, entry.name))
202
+ if (!isDir) continue
203
+ if (this._shouldSkip(currentRel, entry.name)) continue
204
+ if (this._matchSegment(entry.name, segment)) {
205
+ const childRel = currentRel
206
+ ? `${currentRel}/${entry.name}`
207
+ : entry.name
208
+ this._globWalk(childRel, remaining, results, visited)
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Check if a symbolic link points to a directory.
216
+ * @param {string} currentRel - Current relative directory
217
+ * @param {string} entryName - Entry name
218
+ * @returns {boolean}
219
+ */
220
+ _isDirectoryLink(currentRel, entryName) {
221
+ try {
222
+ const full = join(this._root, currentRel, entryName)
223
+ const stat = statSync(full)
224
+ return stat.isDirectory()
225
+ } catch {
226
+ return false
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Resolve the real path for circular symlink detection.
232
+ * @param {string} absPath
233
+ * @returns {string|null}
234
+ */
235
+ _realPath(absPath) {
236
+ try {
237
+ return realpathSync(absPath)
238
+ } catch {
239
+ return null
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Match a filename against a simple glob segment (supports * and ?).
245
+ * @param {string} name
246
+ * @param {string} pattern
247
+ * @returns {boolean}
248
+ */
249
+ _matchSegment(name, pattern) {
250
+ // Convert glob pattern to regex
251
+ let regex = '^'
252
+ for (let i = 0; i < pattern.length; i++) {
253
+ const ch = pattern[i]
254
+ if (ch === '*') {
255
+ regex += '[^/]*'
256
+ } else if (ch === '?') {
257
+ regex += '[^/]'
258
+ } else if (ch === '.') {
259
+ regex += '\\.'
260
+ } else if (ch === '{') {
261
+ // Handle brace expansion: {a,b,c}
262
+ const closeIdx = pattern.indexOf('}', i)
263
+ if (closeIdx !== -1) {
264
+ const alternatives = pattern.substring(i + 1, closeIdx).split(',')
265
+ regex +=
266
+ '(?:' +
267
+ alternatives.map((a) => a.replace(/\./g, '\\.')).join('|') +
268
+ ')'
269
+ i = closeIdx
270
+ } else {
271
+ regex += '\\{'
272
+ }
273
+ } else {
274
+ regex += ch.replace(/[[\]()\\+^$|]/g, '\\$&')
275
+ }
276
+ }
277
+ regex += '$'
278
+ return new RegExp(regex).test(name)
279
+ }
280
+
281
+ /**
282
+ * Check if a directory entry should be skipped during glob traversal.
283
+ * @param {string} currentRel
284
+ * @param {string} entryName
285
+ * @returns {boolean}
286
+ */
287
+ _shouldSkip(currentRel, entryName) {
288
+ if (SKIP_DIRS.has(entryName)) return true
289
+ const entryRel = currentRel ? `${currentRel}/${entryName}` : entryName
290
+ if (SKIP_PATHS.has(entryRel)) return true
291
+ return false
292
+ }
293
+
294
+ /**
295
+ * Execute a shell command in the project root.
296
+ * @param {string} command
297
+ * @returns {Promise<{stdout: string, stderr: string, exitCode: number}>}
298
+ */
299
+ async execCommand(command) {
300
+ try {
301
+ const { stdout, stderr } = await execPromise(command, {
302
+ cwd: this._root,
303
+ maxBuffer: EXEC_MAX_BUFFER,
304
+ timeout: EXEC_TIMEOUT_MS,
305
+ })
306
+ return { stdout: stdout || '', stderr: stderr || '', exitCode: 0 }
307
+ } catch (err) {
308
+ const isTimeout = err.killed && err.signal === 'SIGTERM'
309
+ return {
310
+ stdout: err.stdout || '',
311
+ stderr: isTimeout
312
+ ? `Command timed out after ${EXEC_TIMEOUT_MS}ms: ${command}`
313
+ : err.stderr || '',
314
+ exitCode: err.code || 1,
315
+ }
316
+ }
317
+ }
318
+ }