@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,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 1: Project Context Loader
|
|
3
|
+
*
|
|
4
|
+
* Parses the project's claude.md (or similar instruction file) to extract
|
|
5
|
+
* declared conventions, stack, and project context. This information is
|
|
6
|
+
* compared against actual codebase scan results by the drift detector.
|
|
7
|
+
*
|
|
8
|
+
* @module context-loader
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} ProjectContext
|
|
13
|
+
* @property {boolean} found - Whether a claude.md file was found
|
|
14
|
+
* @property {string|null} raw - Raw file contents
|
|
15
|
+
* @property {Object} declared - Extracted declarations
|
|
16
|
+
* @property {string[]} declared.stack - Declared technology stack items
|
|
17
|
+
* @property {string[]} declared.conventions - Declared coding conventions
|
|
18
|
+
* @property {string[]} declared.patterns - Declared design patterns
|
|
19
|
+
* @property {string[]} declared.gems - Explicitly mentioned gems
|
|
20
|
+
* @property {string[]} declared.testing - Declared testing approach
|
|
21
|
+
* @property {string[]} declared.deployment - Declared deployment approach
|
|
22
|
+
* @property {string|null} declared.rubyVersion - Declared Ruby version
|
|
23
|
+
* @property {string|null} declared.railsVersion - Declared Rails version
|
|
24
|
+
* @property {string[]} warnings - Any parsing warnings
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const SECTION_HEADINGS = /^#{1,3}\s+(.+)/
|
|
28
|
+
const BULLET_ITEM = /^\s*[-*]\s+(.+)/
|
|
29
|
+
const NUMBERED_ITEM = /^\s*\d+[.)]\s+(.+)/
|
|
30
|
+
|
|
31
|
+
const STACK_KEYWORDS =
|
|
32
|
+
/\b(rails|ruby|postgres|postgresql|mysql|sqlite|redis|sidekiq|puma|nginx|docker|kamal|heroku|aws|gcp|elasticsearch|memcached|mongodb|solid.?queue|solid.?cache|solid.?cable|turbo|stimulus|hotwire|webpacker|propshaft|sprockets|import.?maps|tailwind|bootstrap|esbuild|rollup|vite)\b/i
|
|
33
|
+
|
|
34
|
+
const GEM_PATTERN =
|
|
35
|
+
/\b(devise|pundit|cancancan|paper_trail|friendly_id|acts_as_tenant|activeadmin|administrate|avo|ransack|pagy|kaminari|searchkick|pg_search|pay|stripe|rspec|minitest|factory_bot|faker|capybara|rubocop|brakeman|bullet|rack-mini-profiler|faraday|httparty|aasm|statesman|noticed|flipper|discard|paranoia|wicked_pdf|prawn|grover|whenever|sidekiq-cron|action_text|noticed|good_job)\b/i
|
|
36
|
+
|
|
37
|
+
const VERSION_PATTERN = /\bruby\s+(\d+\.\d+(?:\.\d+)?)\b/i
|
|
38
|
+
const RAILS_VERSION_PATTERN = /\brails\s+(\d+\.\d+(?:\.\d+)?)\b/i
|
|
39
|
+
|
|
40
|
+
const CONVENTION_KEYWORDS =
|
|
41
|
+
/\b(convention|pattern|approach|practice|standard|guideline|rule|must|should|always|never|prefer|avoid|use|don't use)\b/i
|
|
42
|
+
|
|
43
|
+
const TESTING_KEYWORDS =
|
|
44
|
+
/\b(rspec|minitest|test|testing|spec|factory|fixture|capybara|system test|integration test|unit test|coverage)\b/i
|
|
45
|
+
|
|
46
|
+
const DEPLOY_KEYWORDS =
|
|
47
|
+
/\b(deploy|kamal|capistrano|heroku|docker|kubernetes|ci|cd|pipeline|staging|production|github actions?)\b/i
|
|
48
|
+
|
|
49
|
+
const PATTERN_KEYWORDS =
|
|
50
|
+
/\b(service object|form object|query object|decorator|presenter|interactor|concern|module|mixin|observer|callback|middleware|serializer|policy|ability)\b/i
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Load and parse project context from a claude.md file.
|
|
54
|
+
*
|
|
55
|
+
* @param {import('../providers/interface.js').FileProvider} provider - File access provider
|
|
56
|
+
* @param {string} [claudeMdPath='claude.md'] - Relative path to the context file
|
|
57
|
+
* @returns {ProjectContext} Parsed project context
|
|
58
|
+
*/
|
|
59
|
+
export function loadProjectContext(provider, claudeMdPath = 'claude.md') {
|
|
60
|
+
const raw = provider.readFile(claudeMdPath)
|
|
61
|
+
|
|
62
|
+
if (raw === null) {
|
|
63
|
+
return {
|
|
64
|
+
found: false,
|
|
65
|
+
raw: null,
|
|
66
|
+
declared: emptyDeclared(),
|
|
67
|
+
warnings: [`No context file found at ${claudeMdPath}`],
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const warnings = []
|
|
72
|
+
const declared = emptyDeclared()
|
|
73
|
+
const lines = raw.split('\n')
|
|
74
|
+
|
|
75
|
+
let currentSection = ''
|
|
76
|
+
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
const headingMatch = line.match(SECTION_HEADINGS)
|
|
79
|
+
if (headingMatch) {
|
|
80
|
+
currentSection = headingMatch[1].toLowerCase().trim()
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const content = extractLineContent(line)
|
|
85
|
+
if (!content) continue
|
|
86
|
+
|
|
87
|
+
// Extract stack items
|
|
88
|
+
const stackMatches = content.match(new RegExp(STACK_KEYWORDS.source, 'gi'))
|
|
89
|
+
if (stackMatches) {
|
|
90
|
+
for (const m of stackMatches) {
|
|
91
|
+
const normalized = m.toLowerCase()
|
|
92
|
+
if (!declared.stack.includes(normalized)) {
|
|
93
|
+
declared.stack.push(normalized)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Extract gem mentions
|
|
99
|
+
const gemMatches = content.match(new RegExp(GEM_PATTERN.source, 'gi'))
|
|
100
|
+
if (gemMatches) {
|
|
101
|
+
for (const m of gemMatches) {
|
|
102
|
+
const normalized = m.toLowerCase()
|
|
103
|
+
if (!declared.gems.includes(normalized)) {
|
|
104
|
+
declared.gems.push(normalized)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Extract versions
|
|
110
|
+
const rubyVer = content.match(VERSION_PATTERN)
|
|
111
|
+
if (rubyVer && !declared.rubyVersion) {
|
|
112
|
+
declared.rubyVersion = rubyVer[1]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const railsVer = content.match(RAILS_VERSION_PATTERN)
|
|
116
|
+
if (railsVer && !declared.railsVersion) {
|
|
117
|
+
declared.railsVersion = railsVer[1]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Classify lines by section context and keywords
|
|
121
|
+
if (isTestingContext(currentSection, content)) {
|
|
122
|
+
addUnique(declared.testing, content)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isDeployContext(currentSection, content)) {
|
|
126
|
+
addUnique(declared.deployment, content)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (isConventionContext(currentSection, content)) {
|
|
130
|
+
addUnique(declared.conventions, content)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (PATTERN_KEYWORDS.test(content)) {
|
|
134
|
+
addUnique(declared.patterns, content)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { found: true, raw, declared, warnings }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @returns {Object} Empty declared structure
|
|
143
|
+
*/
|
|
144
|
+
function emptyDeclared() {
|
|
145
|
+
return {
|
|
146
|
+
stack: [],
|
|
147
|
+
conventions: [],
|
|
148
|
+
patterns: [],
|
|
149
|
+
gems: [],
|
|
150
|
+
testing: [],
|
|
151
|
+
deployment: [],
|
|
152
|
+
rubyVersion: null,
|
|
153
|
+
railsVersion: null,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Extract meaningful content from a line (bullet, numbered, or plain text).
|
|
159
|
+
* @param {string} line
|
|
160
|
+
* @returns {string|null}
|
|
161
|
+
*/
|
|
162
|
+
function extractLineContent(line) {
|
|
163
|
+
const trimmed = line.trim()
|
|
164
|
+
if (!trimmed || trimmed.startsWith('#')) return null
|
|
165
|
+
|
|
166
|
+
const bullet = trimmed.match(BULLET_ITEM)
|
|
167
|
+
if (bullet) return bullet[1].trim()
|
|
168
|
+
|
|
169
|
+
const numbered = trimmed.match(NUMBERED_ITEM)
|
|
170
|
+
if (numbered) return numbered[1].trim()
|
|
171
|
+
|
|
172
|
+
return trimmed
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {string} section
|
|
177
|
+
* @param {string} content
|
|
178
|
+
* @returns {boolean}
|
|
179
|
+
*/
|
|
180
|
+
function isTestingContext(section, content) {
|
|
181
|
+
return (
|
|
182
|
+
section.includes('test') ||
|
|
183
|
+
section.includes('spec') ||
|
|
184
|
+
section.includes('quality') ||
|
|
185
|
+
TESTING_KEYWORDS.test(content)
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {string} section
|
|
191
|
+
* @param {string} content
|
|
192
|
+
* @returns {boolean}
|
|
193
|
+
*/
|
|
194
|
+
function isDeployContext(section, content) {
|
|
195
|
+
return (
|
|
196
|
+
section.includes('deploy') ||
|
|
197
|
+
section.includes('infrastructure') ||
|
|
198
|
+
section.includes('hosting') ||
|
|
199
|
+
DEPLOY_KEYWORDS.test(content)
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {string} section
|
|
205
|
+
* @param {string} content
|
|
206
|
+
* @returns {boolean}
|
|
207
|
+
*/
|
|
208
|
+
function isConventionContext(section, content) {
|
|
209
|
+
return (
|
|
210
|
+
section.includes('convention') ||
|
|
211
|
+
section.includes('standard') ||
|
|
212
|
+
section.includes('guideline') ||
|
|
213
|
+
section.includes('rule') ||
|
|
214
|
+
section.includes('style') ||
|
|
215
|
+
CONVENTION_KEYWORDS.test(content)
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {string[]} arr
|
|
221
|
+
* @param {string} item
|
|
222
|
+
*/
|
|
223
|
+
function addUnique(arr, item) {
|
|
224
|
+
if (!arr.includes(item)) {
|
|
225
|
+
arr.push(item)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convention Drift Detector
|
|
3
|
+
* Compares declared conventions (from claude.md / context) against
|
|
4
|
+
* detected patterns from extractions to flag mismatches.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Detect convention drift between declared and actual state.
|
|
9
|
+
* @param {object} declared - From context loader (claude.md parsed data)
|
|
10
|
+
* @param {object} versions - From version detector
|
|
11
|
+
* @param {object} extractions - All extraction results
|
|
12
|
+
* @returns {Array<{category: string, declared: string, actual: string, severity: string}>}
|
|
13
|
+
*/
|
|
14
|
+
export function detectDrift(declared = {}, versions = {}, extractions = {}) {
|
|
15
|
+
const drift = []
|
|
16
|
+
|
|
17
|
+
detectEnumSyntaxDrift(drift, versions, extractions)
|
|
18
|
+
detectTestingDrift(drift, declared, extractions)
|
|
19
|
+
detectViewsDrift(drift, declared, extractions)
|
|
20
|
+
detectStimulusDrift(drift, declared, extractions)
|
|
21
|
+
detectAuthDrift(drift, declared, extractions)
|
|
22
|
+
|
|
23
|
+
return drift
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check for legacy enum syntax in Rails 7+ apps.
|
|
28
|
+
*/
|
|
29
|
+
function detectEnumSyntaxDrift(drift, versions, extractions) {
|
|
30
|
+
const railsVersion = versions.rails ? parseFloat(versions.rails) : 0
|
|
31
|
+
if (railsVersion < 7.0) return
|
|
32
|
+
|
|
33
|
+
if (extractions.models) {
|
|
34
|
+
let legacyCount = 0
|
|
35
|
+
for (const model of Object.values(extractions.models)) {
|
|
36
|
+
if (model.enums) {
|
|
37
|
+
for (const e of Object.values(model.enums)) {
|
|
38
|
+
// Legacy hash syntax: enum status: { ... }
|
|
39
|
+
if (e.syntax === 'legacy') legacyCount++
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (legacyCount > 0) {
|
|
44
|
+
drift.push({
|
|
45
|
+
category: 'enum_syntax',
|
|
46
|
+
declared: `Rails ${versions.rails} (modern enum syntax expected)`,
|
|
47
|
+
actual: `${legacyCount} model(s) use legacy hash syntax`,
|
|
48
|
+
severity: 'low',
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check for testing convention drift.
|
|
56
|
+
*/
|
|
57
|
+
function detectTestingDrift(drift, declared, extractions) {
|
|
58
|
+
if (!declared.conventions) return
|
|
59
|
+
|
|
60
|
+
const testConventions = declared.conventions.filter((c) =>
|
|
61
|
+
/test|spec|rspec/i.test(c),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
testConventions.length > 0 &&
|
|
66
|
+
extractions.tier2 &&
|
|
67
|
+
extractions.tier2.testing
|
|
68
|
+
) {
|
|
69
|
+
const framework = extractions.tier2.testing.framework
|
|
70
|
+
for (const conv of testConventions) {
|
|
71
|
+
if (/rspec/i.test(conv) && framework === 'minitest') {
|
|
72
|
+
drift.push({
|
|
73
|
+
category: 'testing',
|
|
74
|
+
declared: conv,
|
|
75
|
+
actual: 'Project uses minitest, not rspec',
|
|
76
|
+
severity: 'medium',
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
if (/minitest/i.test(conv) && framework === 'rspec') {
|
|
80
|
+
drift.push({
|
|
81
|
+
category: 'testing',
|
|
82
|
+
declared: conv,
|
|
83
|
+
actual: 'Project uses rspec, not minitest',
|
|
84
|
+
severity: 'medium',
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check for views convention drift.
|
|
93
|
+
*/
|
|
94
|
+
function detectViewsDrift(drift, declared, extractions) {
|
|
95
|
+
if (!declared.conventions) return
|
|
96
|
+
|
|
97
|
+
const viewConventions = declared.conventions.filter((c) =>
|
|
98
|
+
/partial|erb|haml|slim/i.test(c),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
for (const conv of viewConventions) {
|
|
102
|
+
if (/no.*partial/i.test(conv) && extractions.views) {
|
|
103
|
+
const partialCount = extractions.views.partial_renders || 0
|
|
104
|
+
if (partialCount > 0) {
|
|
105
|
+
drift.push({
|
|
106
|
+
category: 'views',
|
|
107
|
+
declared: conv,
|
|
108
|
+
actual: `${partialCount} partials found`,
|
|
109
|
+
severity: 'low',
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check for Stimulus convention drift.
|
|
118
|
+
*/
|
|
119
|
+
function detectStimulusDrift(drift, declared, extractions) {
|
|
120
|
+
if (!declared.conventions) return
|
|
121
|
+
|
|
122
|
+
const stimConventions = declared.conventions.filter((c) =>
|
|
123
|
+
/stimulus|controller/i.test(c),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if (stimConventions.length === 0) return
|
|
127
|
+
|
|
128
|
+
for (const conv of stimConventions) {
|
|
129
|
+
if (/flat/i.test(conv) && extractions.stimulus_controllers) {
|
|
130
|
+
const nested = extractions.stimulus_controllers.some(
|
|
131
|
+
(sc) => sc.identifier && sc.identifier.includes('--'),
|
|
132
|
+
)
|
|
133
|
+
if (nested) {
|
|
134
|
+
drift.push({
|
|
135
|
+
category: 'stimulus',
|
|
136
|
+
declared: conv,
|
|
137
|
+
actual: 'Nested Stimulus controller directories detected',
|
|
138
|
+
severity: 'low',
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check for auth convention drift.
|
|
147
|
+
*/
|
|
148
|
+
function detectAuthDrift(drift, declared, extractions) {
|
|
149
|
+
if (!declared.stack) return
|
|
150
|
+
|
|
151
|
+
const stackAuth = Array.isArray(declared.stack)
|
|
152
|
+
? declared.stack.filter((s) => /devise|auth/i.test(s))
|
|
153
|
+
: []
|
|
154
|
+
|
|
155
|
+
if (stackAuth.length > 0 && extractions.auth) {
|
|
156
|
+
if (
|
|
157
|
+
stackAuth.some((s) => /devise/i.test(s)) &&
|
|
158
|
+
extractions.auth.primary_strategy !== 'devise'
|
|
159
|
+
) {
|
|
160
|
+
drift.push({
|
|
161
|
+
category: 'auth',
|
|
162
|
+
declared: 'Devise in stack',
|
|
163
|
+
actual: `Primary auth strategy: ${extractions.auth.primary_strategy || 'none'}`,
|
|
164
|
+
severity: 'medium',
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-Budgeted Formatter
|
|
3
|
+
* Uses binary search to find maximal content within a token budget.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { estimateTokens } from '../utils/token-counter.js'
|
|
7
|
+
|
|
8
|
+
/** Default token budget */
|
|
9
|
+
const DEFAULT_BUDGET = 12000
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format index output to fit within a token budget.
|
|
13
|
+
* @param {object} fullIndex - Complete index object
|
|
14
|
+
* @param {number} [tokenBudget] - Target token budget
|
|
15
|
+
* @returns {object} Trimmed index
|
|
16
|
+
*/
|
|
17
|
+
export function formatOutput(fullIndex, tokenBudget = DEFAULT_BUDGET) {
|
|
18
|
+
if (!fullIndex || typeof fullIndex !== 'object') return {}
|
|
19
|
+
|
|
20
|
+
const fullJson = JSON.stringify(fullIndex)
|
|
21
|
+
const fullTokens = estimateTokens(fullJson)
|
|
22
|
+
|
|
23
|
+
// If it fits, return as-is
|
|
24
|
+
if (fullTokens <= tokenBudget) return fullIndex
|
|
25
|
+
|
|
26
|
+
// Priority-ordered sections to include
|
|
27
|
+
const sections = [
|
|
28
|
+
'version',
|
|
29
|
+
'generated_at',
|
|
30
|
+
'versions',
|
|
31
|
+
'statistics',
|
|
32
|
+
'context',
|
|
33
|
+
'manifest',
|
|
34
|
+
'drift',
|
|
35
|
+
'rankings',
|
|
36
|
+
'relationships',
|
|
37
|
+
'extractions',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
// Build output by adding sections in priority order
|
|
41
|
+
const result = {}
|
|
42
|
+
let currentTokens = 2 // for {}
|
|
43
|
+
|
|
44
|
+
for (const section of sections) {
|
|
45
|
+
if (!(section in fullIndex)) continue
|
|
46
|
+
|
|
47
|
+
const value = fullIndex[section]
|
|
48
|
+
const sectionJson = JSON.stringify({ [section]: value })
|
|
49
|
+
const sectionTokens = estimateTokens(sectionJson)
|
|
50
|
+
|
|
51
|
+
if (currentTokens + sectionTokens <= tokenBudget) {
|
|
52
|
+
result[section] = value
|
|
53
|
+
currentTokens += sectionTokens
|
|
54
|
+
} else {
|
|
55
|
+
// Try to fit a trimmed version of the section
|
|
56
|
+
const trimmed = trimSection(section, value, tokenBudget - currentTokens)
|
|
57
|
+
if (trimmed !== null) {
|
|
58
|
+
result[section] = trimmed
|
|
59
|
+
currentTokens += estimateTokens(JSON.stringify({ [section]: trimmed }))
|
|
60
|
+
}
|
|
61
|
+
// Continue to try remaining sections
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Add any remaining top-level keys not in priority list
|
|
66
|
+
for (const key of Object.keys(fullIndex)) {
|
|
67
|
+
if (key in result) continue
|
|
68
|
+
const val = fullIndex[key]
|
|
69
|
+
const kJson = JSON.stringify({ [key]: val })
|
|
70
|
+
const kTokens = estimateTokens(kJson)
|
|
71
|
+
if (currentTokens + kTokens <= tokenBudget) {
|
|
72
|
+
result[key] = val
|
|
73
|
+
currentTokens += kTokens
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return result
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Trim a section to fit within available tokens.
|
|
82
|
+
* @param {string} sectionName
|
|
83
|
+
* @param {*} value
|
|
84
|
+
* @param {number} availableTokens
|
|
85
|
+
* @returns {*} Trimmed value or null
|
|
86
|
+
*/
|
|
87
|
+
function trimSection(sectionName, value, availableTokens) {
|
|
88
|
+
if (availableTokens <= 10) return null
|
|
89
|
+
|
|
90
|
+
if (sectionName === 'extractions' && typeof value === 'object') {
|
|
91
|
+
return trimExtractions(value, availableTokens)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
const trimmed = trimArray(value, availableTokens)
|
|
96
|
+
return trimmed.length > 0 ? trimmed : null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof value === 'object' && value !== null) {
|
|
100
|
+
return trimObject(value, availableTokens)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Trim extractions to fit available tokens using binary search.
|
|
108
|
+
* Higher priority sections are kept first.
|
|
109
|
+
*/
|
|
110
|
+
function trimExtractions(extractions, availableTokens) {
|
|
111
|
+
const priorityOrder = [
|
|
112
|
+
'gemfile',
|
|
113
|
+
'schema',
|
|
114
|
+
'models',
|
|
115
|
+
'controllers',
|
|
116
|
+
'routes',
|
|
117
|
+
'auth',
|
|
118
|
+
'config',
|
|
119
|
+
'jobs',
|
|
120
|
+
'email',
|
|
121
|
+
'storage',
|
|
122
|
+
'caching',
|
|
123
|
+
'realtime',
|
|
124
|
+
'api',
|
|
125
|
+
'views',
|
|
126
|
+
'components',
|
|
127
|
+
'stimulus',
|
|
128
|
+
'authorization',
|
|
129
|
+
'tier2',
|
|
130
|
+
'tier3',
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
const keys = Object.keys(extractions)
|
|
134
|
+
const ordered = [
|
|
135
|
+
...priorityOrder.filter((k) => keys.includes(k)),
|
|
136
|
+
...keys.filter((k) => !priorityOrder.includes(k)),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
const result = {}
|
|
140
|
+
let usedTokens = 2
|
|
141
|
+
|
|
142
|
+
for (const key of ordered) {
|
|
143
|
+
const json = JSON.stringify({ [key]: extractions[key] })
|
|
144
|
+
const tokens = estimateTokens(json)
|
|
145
|
+
if (usedTokens + tokens <= availableTokens) {
|
|
146
|
+
result[key] = extractions[key]
|
|
147
|
+
usedTokens += tokens
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return Object.keys(result).length > 0 ? result : null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Binary search to find maximal array slice within budget.
|
|
156
|
+
*/
|
|
157
|
+
function trimArray(arr, availableTokens) {
|
|
158
|
+
if (!arr.length) return arr
|
|
159
|
+
|
|
160
|
+
let lo = 0
|
|
161
|
+
let hi = arr.length
|
|
162
|
+
let best = 0
|
|
163
|
+
|
|
164
|
+
while (lo <= hi) {
|
|
165
|
+
const mid = Math.floor((lo + hi) / 2)
|
|
166
|
+
const slice = arr.slice(0, mid)
|
|
167
|
+
const tokens = estimateTokens(JSON.stringify(slice))
|
|
168
|
+
if (tokens <= availableTokens) {
|
|
169
|
+
best = mid
|
|
170
|
+
lo = mid + 1
|
|
171
|
+
} else {
|
|
172
|
+
hi = mid - 1
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return arr.slice(0, best)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Trim an object to fit available tokens by including keys greedily.
|
|
181
|
+
*/
|
|
182
|
+
function trimObject(obj, availableTokens) {
|
|
183
|
+
const keys = Object.keys(obj)
|
|
184
|
+
const result = {}
|
|
185
|
+
let used = 2 // {}
|
|
186
|
+
|
|
187
|
+
for (const key of keys) {
|
|
188
|
+
const entryJson = JSON.stringify({ [key]: obj[key] })
|
|
189
|
+
const entryTokens = estimateTokens(entryJson)
|
|
190
|
+
if (used + entryTokens <= availableTokens) {
|
|
191
|
+
result[key] = obj[key]
|
|
192
|
+
used += entryTokens
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return Object.keys(result).length > 0 ? result : null
|
|
197
|
+
}
|