offbyt 1.0.0 → 1.0.2

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.
@@ -1,90 +1,93 @@
1
1
  /**
2
- * Resource Detector - Detects data resources from frontend code patterns
3
- * Detects from: useState, useEffect, data arrays, forms, etc.
2
+ * Resource Detector - Detects data resources from frontend code patterns.
3
+ * Detects from state, map/list rendering, forms, and existing API calls.
4
4
  */
5
5
 
6
6
  import fs from 'fs';
7
7
  import path from 'path';
8
8
  import { globSync } from 'glob';
9
- import * as parser from '@babel/parser';
10
- import traverse from '@babel/traverse';
11
9
 
12
- /**
13
- * Scan frontend and detect resources from state variables and data patterns
14
- */
15
10
  export function detectResourcesFromFrontend(projectPath) {
16
11
  const resources = new Map();
17
-
18
- // Find all frontend files
19
- const patterns = [
20
- 'src/**/*.{js,jsx,ts,tsx}',
21
- 'app/**/*.{js,jsx,ts,tsx}',
22
- 'pages/**/*.{js,jsx,ts,tsx}',
23
- 'components/**/*.{js,jsx,ts,tsx}',
24
- '*.{js,jsx,ts,tsx}'
25
- ];
12
+ const files = getFrontendFiles(projectPath);
26
13
 
27
- const files = [];
28
- for (const pattern of patterns) {
29
- files.push(...globSync(pattern, { nodir: true, cwd: projectPath }));
30
- }
31
-
32
- console.log(` 📁 Scanning ${files.length} files...`);
14
+ console.log(` Scanning ${files.length} files...`);
33
15
 
34
16
  for (const file of files) {
35
17
  try {
36
18
  const fullPath = path.join(projectPath, file);
37
19
  const content = fs.readFileSync(fullPath, 'utf8');
38
-
39
- // Detect resources from code patterns
40
20
  const detected = detectFromCode(content, file);
41
-
21
+
42
22
  for (const resource of detected) {
43
- if (!resources.has(resource.name)) {
44
- resources.set(resource.name, {
45
- name: resource.name,
46
- singular: resource.singular,
47
- fields: new Set(resource.fields || []),
23
+ const normalizedName = normalizeResourceName(resource.name);
24
+ if (!normalizedName) continue;
25
+
26
+ if (!resources.has(normalizedName)) {
27
+ resources.set(normalizedName, {
28
+ name: normalizedName,
29
+ singular: singularize(normalizedName),
30
+ fields: new Set(),
48
31
  sources: []
49
32
  });
50
33
  }
51
-
52
- resources.get(resource.name).sources.push({
53
- file,
54
- type: resource.type
55
- });
56
-
57
- // Merge fields
58
- if (resource.fields) {
59
- for (const field of resource.fields) {
60
- resources.get(resource.name).fields.add(field);
34
+
35
+ const existing = resources.get(normalizedName);
36
+ existing.sources.push({ file, type: resource.type });
37
+
38
+ for (const field of resource.fields || []) {
39
+ if (!isIgnoredField(field)) {
40
+ existing.fields.add(field);
61
41
  }
62
42
  }
63
43
  }
64
- } catch (error) {
65
- // Ignore parse errors
44
+ } catch {
45
+ // Ignore unreadable files.
66
46
  }
67
47
  }
68
48
 
69
- // Convert to array
70
- return Array.from(resources.values()).map(r => ({
71
- ...r,
72
- fields: Array.from(r.fields).filter(field =>
73
- field !== '_id' && field !== 'id' && field.toLowerCase() !== 'id'
74
- )
49
+ return Array.from(resources.values()).map((resource) => ({
50
+ ...resource,
51
+ fields: Array.from(resource.fields)
75
52
  }));
76
53
  }
77
54
 
78
- /**
79
- * Detect resources from code content using AST and patterns
80
- */
55
+ function getFrontendFiles(projectPath) {
56
+ const patterns = [
57
+ 'src/**/*.{js,jsx,ts,tsx}',
58
+ 'app/**/*.{js,jsx,ts,tsx}',
59
+ 'pages/**/*.{js,jsx,ts,tsx}',
60
+ 'components/**/*.{js,jsx,ts,tsx}',
61
+ '*.{js,jsx,ts,tsx}'
62
+ ];
63
+
64
+ const ignored = ['node_modules', 'backend', 'dist', 'build', '.next', 'out', 'coverage'];
65
+ const files = new Set();
66
+
67
+ for (const pattern of patterns) {
68
+ const found = globSync(pattern, {
69
+ cwd: projectPath,
70
+ nodir: true,
71
+ ignore: ignored.map((dir) => `${dir}/**`)
72
+ });
73
+
74
+ for (const file of found) {
75
+ files.add(file);
76
+ }
77
+ }
78
+
79
+ return Array.from(files);
80
+ }
81
+
81
82
  function detectFromCode(content, fileName) {
82
83
  const resources = [];
84
+ const statePattern = /const\s*\[\s*(\w+)\s*,\s*set\w+\s*\]\s*=\s*useState(?:<[^>]+>)?\s*\(/g;
85
+ const arrayPattern = /const\s+(\w+)\s*=\s*\[([\s\S]*?)\]\s*;?/g;
86
+ const mapPattern = /(\w+)\.map\s*\(\s*(?:\()?([a-zA-Z_$][\w$]*)(?:\))?\s*=>/g;
87
+ const inputPattern = /name=["']([a-zA-Z_$][\w$]*)["']/g;
83
88
 
84
- // Pattern 1: useState hooks
85
- // Example: const [products, setProducts] = useState([])
86
- const statePattern = /const\s+\[(\w+),\s*set\w+\]\s*=\s*useState/g;
87
89
  let match;
90
+
88
91
  while ((match = statePattern.exec(content)) !== null) {
89
92
  const varName = match[1];
90
93
  if (isResourceVariable(varName)) {
@@ -92,190 +95,252 @@ function detectFromCode(content, fileName) {
92
95
  name: varName,
93
96
  singular: singularize(varName),
94
97
  type: 'useState',
95
- fields: extractFieldsFromVariable(content, varName)
98
+ fields: extractFieldsFromListVariable(content, varName)
96
99
  });
97
100
  }
98
101
  }
99
102
 
100
- // Pattern 2: Data arrays
101
- // Example: const products = []
102
- const arrayPattern = /const\s+(\w+)\s*=\s*\[\]/g;
103
103
  while ((match = arrayPattern.exec(content)) !== null) {
104
104
  const varName = match[1];
105
+ const arrayLiteral = match[2] || '';
105
106
  if (isResourceVariable(varName)) {
106
107
  resources.push({
107
108
  name: varName,
108
109
  singular: singularize(varName),
109
110
  type: 'array',
110
- fields: extractFieldsFromVariable(content, varName)
111
+ fields: [
112
+ ...extractFieldsFromListVariable(content, varName),
113
+ ...extractFieldsFromArrayLiteral(arrayLiteral)
114
+ ]
111
115
  });
112
116
  }
113
117
  }
114
118
 
115
- // Pattern 3: map operations (indicates list rendering)
116
- // Example: products.map(product => ...)
117
- const mapPattern = /(\w+)\.map\s*\(\s*(?:\()?(\w+)(?:\))?\s*=>/g;
118
119
  while ((match = mapPattern.exec(content)) !== null) {
119
120
  const listName = match[1];
120
121
  const itemName = match[2];
121
-
122
122
  if (isResourceVariable(listName)) {
123
- const fields = extractFieldsFromItemVariable(content, itemName);
124
123
  resources.push({
125
124
  name: listName,
126
- singular: itemName,
125
+ singular: singularize(listName),
127
126
  type: 'map',
128
- fields
127
+ fields: extractFieldsFromItemVariable(content, itemName)
129
128
  });
130
129
  }
131
130
  }
132
131
 
133
- // Pattern 4: Form fields (input names)
134
- // Example: <input name="productName" />
135
- const inputPattern = /name=["'](\w+)["']/g;
136
132
  const formFields = [];
137
133
  while ((match = inputPattern.exec(content)) !== null) {
138
134
  formFields.push(match[1]);
139
135
  }
140
136
 
141
137
  if (formFields.length > 0) {
142
- // Try to infer resource from form fields
143
- const resourceName = inferResourceFromFields(formFields);
144
- if (resourceName) {
138
+ const inferred = inferResourceFromFields(formFields, fileName);
139
+ if (inferred) {
145
140
  resources.push({
146
- name: resourceName,
147
- singular: singularize(resourceName),
141
+ name: inferred,
142
+ singular: singularize(inferred),
148
143
  type: 'form',
149
144
  fields: formFields
150
145
  });
151
146
  }
152
147
  }
153
148
 
154
- // Pattern 5: useEffect with fetch/axios placeholder
155
- // Example: useEffect(() => { /* TODO: fetch products */ }, [])
156
- const effectPattern = /useEffect\s*\([^)]*?(?:fetch|load|get)\s*(\w+)/gi;
157
- while ((match = effectPattern.exec(content)) !== null) {
158
- const varName = match[1];
159
- if (isResourceVariable(varName)) {
160
- resources.push({
161
- name: varName,
162
- singular: singularize(varName),
163
- type: 'useEffect'
164
- });
165
- }
166
- }
149
+ resources.push(...extractResourcesFromApiCalls(content));
167
150
 
168
151
  return resources;
169
152
  }
170
153
 
171
- /**
172
- * Check if variable name looks like a resource
173
- */
174
- function isResourceVariable(name) {
175
- // Skip common non-resource variables
176
- const excluded = ['data', 'loading', 'error', 'fetching', 'isLoading', 'isError',
177
- 'response', 'result', 'value', 'state', 'props', 'params', 'filter',
178
- 'filtered', 'sorted', 'paginated', 'searched'];
179
-
180
- // Skip derived/transformed variables (filteredTodos, sortedProducts, etc.)
181
- const derivedPrefixes = ['filtered', 'sorted', 'paginated', 'searched', 'visible', 'active', 'selected', 'new'];
182
- if (derivedPrefixes.some(prefix => name.toLowerCase().startsWith(prefix))) {
183
- return false;
154
+ function extractResourcesFromApiCalls(content) {
155
+ const results = [];
156
+
157
+ const fetchPattern = /fetch\s*\(\s*([`"'])([\s\S]*?)\1/g;
158
+ const axiosPattern = /axios\.(get|post|put|patch|delete)\s*\(\s*([`"'])([\s\S]*?)\2/g;
159
+
160
+ let match;
161
+ while ((match = fetchPattern.exec(content)) !== null) {
162
+ const resource = getResourceFromUrlString(match[2]);
163
+ if (resource) {
164
+ results.push({ name: resource, singular: singularize(resource), type: 'fetch', fields: [] });
184
165
  }
185
-
186
- if (excluded.includes(name.toLowerCase())) return false;
187
-
188
- // Should be plural or common resource name
189
- return name.length > 2 && (
190
- name.endsWith('s') ||
191
- name.endsWith('List') ||
192
- name.endsWith('Data') ||
193
- isCommonResource(name)
194
- );
166
+ }
167
+
168
+ while ((match = axiosPattern.exec(content)) !== null) {
169
+ const resource = getResourceFromUrlString(match[3]);
170
+ if (resource) {
171
+ results.push({ name: resource, singular: singularize(resource), type: 'axios', fields: [] });
172
+ }
173
+ }
174
+
175
+ return results;
195
176
  }
196
177
 
197
- /**
198
- * Check if it's a common resource name
199
- */
200
- function isCommonResource(name) {
201
- const common = ['user', 'product', 'order', 'cart', 'item', 'post', 'comment',
202
- 'review', 'category', 'tag', 'customer', 'invoice', 'payment'];
203
- const lowerName = name.toLowerCase();
204
- // Check if name equals or ends with (singular or plural) a common resource
205
- return common.some(r => {
206
- const plural = r + 's';
207
- return lowerName === r || lowerName === plural ||
208
- lowerName.endsWith(r) && (lowerName[lowerName.length - r.length - 1] === 's' || lowerName.length === r.length);
209
- });
178
+ function getResourceFromUrlString(rawUrl) {
179
+ if (!rawUrl) return null;
180
+
181
+ let url = rawUrl
182
+ .replace(/\$\{[^}]+\}/g, '')
183
+ .replace(/https?:\/\/[^/]+/g, '')
184
+ .trim();
185
+
186
+ if (!url) return null;
187
+
188
+ const apiIndex = url.indexOf('/api/');
189
+ if (apiIndex !== -1) {
190
+ url = url.slice(apiIndex + 5);
191
+ }
192
+
193
+ if (url.startsWith('/')) {
194
+ url = url.slice(1);
195
+ }
196
+
197
+ url = url.split('?')[0].split('#')[0];
198
+ if (!url) return null;
199
+
200
+ const firstSegment = url.split('/')[0];
201
+ if (!firstSegment) return null;
202
+
203
+ if (firstSegment.startsWith(':') || firstSegment === 'health') return null;
204
+
205
+ return normalizeResourceName(firstSegment);
210
206
  }
211
207
 
212
- /**
213
- * Convert plural to singular (simple version)
214
- */
215
- function singularize(word) {
216
- if (word.endsWith('ies')) return word.slice(0, -3) + 'y';
217
- if (word.endsWith('ses')) return word.slice(0, -2);
218
- if (word.endsWith('s')) return word.slice(0, -1);
219
- return word;
208
+ function normalizeResourceName(name) {
209
+ if (!name) return null;
210
+
211
+ let normalized = name
212
+ .replace(/[^a-zA-Z0-9_]/g, '')
213
+ .replace(/^(get|fetch|load)/i, '')
214
+ .replace(/(List|Data)$/i, '')
215
+ .trim();
216
+
217
+ if (!normalized) return null;
218
+
219
+ const lowered = normalized.toLowerCase();
220
+ if (lowered.length < 3) return null;
221
+ if (!isResourceVariable(lowered)) return null;
222
+
223
+ if (!lowered.endsWith('s')) {
224
+ return `${lowered}s`;
225
+ }
226
+
227
+ return lowered;
220
228
  }
221
229
 
222
- /**
223
- * Extract fields from variable usage in code
224
- */
225
- function extractFieldsFromVariable(content, varName) {
230
+ function extractFieldsFromListVariable(content, varName) {
226
231
  const fields = new Set();
227
-
228
- // Pattern: varName.field
229
- const dotPattern = new RegExp(`${varName}\\.\\w+\\.(\\w+)`, 'g');
232
+ const nestedAccessPattern = new RegExp(`${varName}\\.\\w+\\.([a-zA-Z_$][\\w$]*)`, 'g');
233
+
230
234
  let match;
231
- while ((match = dotPattern.exec(content)) !== null) {
232
- fields.add(match[1]);
235
+ while ((match = nestedAccessPattern.exec(content)) !== null) {
236
+ if (!isIgnoredField(match[1])) {
237
+ fields.add(match[1]);
238
+ }
233
239
  }
234
-
235
- return Array.from(fields).filter(field =>
236
- field !== '_id' && field !== 'id' && field.toLowerCase() !== 'id'
237
- );
240
+
241
+ return Array.from(fields);
238
242
  }
239
243
 
240
- /**
241
- * Extract fields from item variable in map operations
242
- */
243
244
  function extractFieldsFromItemVariable(content, itemName) {
244
245
  const fields = new Set();
245
-
246
- // Pattern: item.fieldName
247
- const fieldPattern = new RegExp(`${itemName}\\.([a-zA-Z_][a-zA-Z0-9_]*)`, 'g');
246
+ const fieldPattern = new RegExp(`${itemName}\\.([a-zA-Z_$][a-zA-Z0-9_$]*)`, 'g');
247
+
248
248
  let match;
249
249
  while ((match = fieldPattern.exec(content)) !== null) {
250
250
  const field = match[1];
251
- // Skip common methods and MongoDB _id
252
- if (!['map', 'filter', 'reduce', 'forEach', 'length'].includes(field) &&
253
- field !== '_id' && field !== 'id' && field.toLowerCase() !== 'id') {
251
+ if (!['map', 'filter', 'reduce', 'forEach', 'length'].includes(field) && !isIgnoredField(field)) {
254
252
  fields.add(field);
255
253
  }
256
254
  }
257
-
255
+
258
256
  return Array.from(fields);
259
257
  }
260
258
 
261
- /**
262
- * Infer resource name from form fields
263
- */
264
- function inferResourceFromFields(fields) {
265
- // Look for common patterns in field names
266
- const patterns = {
267
- product: ['productName', 'productPrice', 'productDescription'],
268
- user: ['userName', 'userEmail', 'userPassword'],
269
- order: ['orderDate', 'orderTotal', 'orderStatus']
270
- };
271
-
272
- for (const [resource, keywords] of Object.entries(patterns)) {
273
- if (keywords.some(kw => fields.some(f => f.toLowerCase().includes(kw.toLowerCase())))) {
274
- return resource + 's';
259
+ function extractFieldsFromArrayLiteral(arrayLiteral) {
260
+ if (!arrayLiteral) return [];
261
+
262
+ const fields = new Set();
263
+ const keyPattern = /([A-Za-z_$][A-Za-z0-9_$]*)\s*:/g;
264
+ let match;
265
+
266
+ while ((match = keyPattern.exec(arrayLiteral)) !== null) {
267
+ const field = match[1];
268
+ if (!isIgnoredField(field)) {
269
+ fields.add(field);
275
270
  }
276
271
  }
277
-
272
+
273
+ return Array.from(fields);
274
+ }
275
+
276
+ function inferResourceFromFields(fields, fileName = '') {
277
+ const byPrefix = {};
278
+
279
+ for (const field of fields) {
280
+ const clean = field.trim();
281
+ const camelPrefix = clean.match(/^([a-z][a-z0-9]+)[A-Z]/);
282
+ if (camelPrefix) {
283
+ const key = camelPrefix[1].toLowerCase();
284
+ byPrefix[key] = (byPrefix[key] || 0) + 1;
285
+ }
286
+ }
287
+
288
+ const candidate = Object.entries(byPrefix)
289
+ .sort((a, b) => b[1] - a[1])[0]?.[0];
290
+
291
+ if (candidate && candidate.length > 2) {
292
+ return normalizeResourceName(candidate.endsWith('s') ? candidate : `${candidate}s`);
293
+ }
294
+
295
+ const fileBase = path.basename(fileName, path.extname(fileName)).toLowerCase();
296
+ if (isResourceVariable(fileBase)) {
297
+ return normalizeResourceName(fileBase.endsWith('s') ? fileBase : `${fileBase}s`);
298
+ }
299
+
278
300
  return null;
279
301
  }
280
302
 
303
+ function isResourceVariable(name) {
304
+ const lower = String(name || '').toLowerCase();
305
+
306
+ const excluded = new Set([
307
+ 'data', 'loading', 'error', 'fetching', 'isloading', 'iserror', 'response', 'result',
308
+ 'value', 'state', 'props', 'params', 'filter', 'filtered', 'sorted', 'paginated',
309
+ 'searched', 'config', 'item', 'items', 'list', 'payload'
310
+ ]);
311
+
312
+ const derivedPrefixes = ['filtered', 'sorted', 'paginated', 'searched', 'visible', 'active', 'selected', 'new'];
313
+ if (excluded.has(lower)) return false;
314
+ if (derivedPrefixes.some((prefix) => lower.startsWith(prefix))) return false;
315
+
316
+ return lower.length > 2 && (
317
+ lower.endsWith('s') ||
318
+ lower.endsWith('list') ||
319
+ lower.endsWith('data') ||
320
+ isCommonResource(lower)
321
+ );
322
+ }
323
+
324
+ function isCommonResource(name) {
325
+ const common = [
326
+ 'user', 'product', 'order', 'cart', 'item', 'post', 'comment', 'review',
327
+ 'category', 'tag', 'customer', 'invoice', 'payment', 'task', 'project',
328
+ 'message', 'notification', 'ticket', 'booking'
329
+ ];
330
+
331
+ return common.some((base) => name === base || name === `${base}s`);
332
+ }
333
+
334
+ function singularize(word) {
335
+ if (word.endsWith('ies')) return word.slice(0, -3) + 'y';
336
+ if (word.endsWith('ses')) return word.slice(0, -2);
337
+ if (word.endsWith('s')) return word.slice(0, -1);
338
+ return word;
339
+ }
340
+
341
+ function isIgnoredField(field) {
342
+ const lower = String(field || '').toLowerCase();
343
+ return lower === '_id' || lower === 'id';
344
+ }
345
+
281
346
  export default { detectResourcesFromFrontend };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offbyt",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "description": "Code generation and deployment tool",
6
6
  "main": "index.js",