@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.
- package/LICENSE +15 -0
- package/README.md +210 -0
- package/bin/railsinsight.js +128 -0
- package/package.json +62 -0
- package/src/core/blast-radius.js +496 -0
- package/src/core/constants.js +39 -0
- package/src/core/context-loader.js +227 -0
- package/src/core/drift-detector.js +168 -0
- package/src/core/formatter.js +197 -0
- package/src/core/graph.js +510 -0
- package/src/core/indexer.js +595 -0
- package/src/core/patterns/api.js +27 -0
- package/src/core/patterns/auth.js +25 -0
- package/src/core/patterns/authorization.js +24 -0
- package/src/core/patterns/caching.js +19 -0
- package/src/core/patterns/component.js +18 -0
- package/src/core/patterns/config.js +15 -0
- package/src/core/patterns/controller.js +42 -0
- package/src/core/patterns/email.js +20 -0
- package/src/core/patterns/factory.js +31 -0
- package/src/core/patterns/gemfile.js +9 -0
- package/src/core/patterns/helper.js +10 -0
- package/src/core/patterns/job.js +12 -0
- package/src/core/patterns/model.js +123 -0
- package/src/core/patterns/realtime.js +17 -0
- package/src/core/patterns/route.js +27 -0
- package/src/core/patterns/schema.js +25 -0
- package/src/core/patterns/stimulus.js +13 -0
- package/src/core/patterns/storage.js +16 -0
- package/src/core/patterns/uploader.js +16 -0
- package/src/core/patterns/view.js +20 -0
- package/src/core/patterns/worker.js +12 -0
- package/src/core/patterns.js +27 -0
- package/src/core/scanner.js +394 -0
- package/src/core/version-detector.js +295 -0
- package/src/extractors/api.js +284 -0
- package/src/extractors/auth.js +853 -0
- package/src/extractors/authorization.js +785 -0
- package/src/extractors/caching.js +84 -0
- package/src/extractors/component.js +221 -0
- package/src/extractors/config.js +81 -0
- package/src/extractors/controller.js +273 -0
- package/src/extractors/coverage-snapshot.js +296 -0
- package/src/extractors/email.js +123 -0
- package/src/extractors/factory-registry.js +225 -0
- package/src/extractors/gemfile.js +440 -0
- package/src/extractors/helper.js +55 -0
- package/src/extractors/jobs.js +122 -0
- package/src/extractors/model.js +506 -0
- package/src/extractors/realtime.js +102 -0
- package/src/extractors/routes.js +251 -0
- package/src/extractors/schema.js +178 -0
- package/src/extractors/stimulus.js +149 -0
- package/src/extractors/storage.js +100 -0
- package/src/extractors/test-conventions.js +340 -0
- package/src/extractors/tier2.js +417 -0
- package/src/extractors/tier3.js +84 -0
- package/src/extractors/uploader.js +138 -0
- package/src/extractors/views.js +131 -0
- package/src/extractors/worker.js +62 -0
- package/src/git/diff-parser.js +132 -0
- package/src/providers/interface.js +12 -0
- package/src/providers/local-fs.js +318 -0
- package/src/server.js +71 -0
- package/src/tools/blast-radius-tools.js +129 -0
- package/src/tools/free-tools.js +44 -0
- package/src/tools/handlers/get-controller.js +93 -0
- package/src/tools/handlers/get-coverage-gaps.js +100 -0
- package/src/tools/handlers/get-deep-analysis.js +294 -0
- package/src/tools/handlers/get-domain-clusters.js +113 -0
- package/src/tools/handlers/get-factory-registry.js +43 -0
- package/src/tools/handlers/get-full-index.js +28 -0
- package/src/tools/handlers/get-model.js +108 -0
- package/src/tools/handlers/get-overview.js +153 -0
- package/src/tools/handlers/get-routes.js +18 -0
- package/src/tools/handlers/get-schema.js +40 -0
- package/src/tools/handlers/get-subgraph.js +82 -0
- package/src/tools/handlers/get-test-conventions.js +18 -0
- package/src/tools/handlers/get-well-tested-examples.js +51 -0
- package/src/tools/handlers/helpers.js +115 -0
- package/src/tools/handlers/index-project.js +36 -0
- package/src/tools/handlers/search-patterns.js +104 -0
- package/src/tools/index.js +34 -0
- package/src/tools/pro-tools.js +13 -0
- package/src/utils/file-reader.js +20 -0
- package/src/utils/inflector.js +223 -0
- package/src/utils/ruby-parser.js +115 -0
- package/src/utils/spec-style-detector.js +26 -0
- package/src/utils/token-counter.js +46 -0
- 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
|
+
}
|