@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,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
+ }