backend-skeleton 1.7.0 → 1.8.0

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.
@@ -0,0 +1,578 @@
1
+ // Ruby on Rails scanner adapter. The default path is deliberately static and never boots the
2
+ // target application. `bskel scan --runtime-routes` is the explicit opt-in that calls the
3
+ // framework's own `bin/rails routes --expanded`; see introspectRailsRoutes() below.
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { byShallowestThenName, binaryAvailable, lineNumberAt, listRgFiles } from '../text-util.mjs';
9
+
10
+ // Large Rails applications may depend on the component gems directly instead of the `rails`
11
+ // meta-gem (Discourse does this with `railties` plus action*/active*). `railties` is the precise
12
+ // boot/framework marker; the Rails::Application + routes.rb checks below still prevent a library
13
+ // that merely depends on railties from being mistaken for an application.
14
+ const RAILS_DEP_RE = /(?:^|\n)\s*(?:gem\s*[ (]?\s*["'](?:rails|railties)["']|(?:rails|railties)\s*\()/m;
15
+ const RAILS_APP_RE = /class\s+[A-Z]\w*(?:::[A-Z]\w*)*\s*<\s*Rails::Application\b/;
16
+ const HTTP_VERBS = new Set(['get', 'post', 'put', 'patch', 'delete']);
17
+ const RESOURCE_ACTIONS = Object.freeze(['index', 'create', 'new', 'show', 'edit', 'update', 'destroy']);
18
+ // These are rooted at the detected Rails project. A `!**/tmp/**` form also matches an absolute
19
+ // checkout path under the operating system's /tmp directory and excludes the entire repo on
20
+ // Linux (the real oracle harness clones there), not merely Rails' own tmp/ subtree.
21
+ const EXCLUDE_GLOBS = ['!.bundle/**', '!vendor/bundle/**', '!tmp/**', '!log/**', '!node_modules/**'];
22
+
23
+ function railsFiles(root, globs) {
24
+ return listRgFiles(root, globs, EXCLUDE_GLOBS);
25
+ }
26
+
27
+ function rubyFilesUnder(projectRoot, prefixes) {
28
+ return railsFiles(projectRoot, ['*.rb']).filter((file) => {
29
+ const rel = path.relative(projectRoot, file).split(path.sep).join('/');
30
+ return prefixes.some((prefix) => rel === prefix || rel.startsWith(`${prefix}/`));
31
+ });
32
+ }
33
+
34
+ function routeFiles(projectRoot) {
35
+ return rubyFilesUnder(projectRoot, ['config/routes.rb', 'config/routes']);
36
+ }
37
+
38
+ function railsReadFiles(projectRoot) {
39
+ const markers = railsFiles(projectRoot, ['Gemfile', 'Gemfile.lock']);
40
+ const ruby = rubyFilesUnder(projectRoot, ['config', 'app/controllers', 'app/models', 'lib']);
41
+ return [...new Set([...markers, ...ruby])].sort();
42
+ }
43
+
44
+ function candidateProjectRoots(repoRoot) {
45
+ const roots = new Set();
46
+ for (const file of railsFiles(repoRoot, ['Gemfile', 'Gemfile.lock'])) roots.add(path.dirname(file));
47
+ return [...roots].sort(byShallowestThenName);
48
+ }
49
+
50
+ function declaresRails(projectRoot) {
51
+ for (const name of ['Gemfile', 'Gemfile.lock']) {
52
+ const file = path.join(projectRoot, name);
53
+ try {
54
+ if (RAILS_DEP_RE.test(fs.readFileSync(file, 'utf8'))) return true;
55
+ } catch {
56
+ // optional marker file
57
+ }
58
+ }
59
+ return false;
60
+ }
61
+
62
+ export function detectRubyRailsRoot(repoRoot) {
63
+ for (const projectRoot of candidateProjectRoots(repoRoot)) {
64
+ if (!declaresRails(projectRoot)) continue;
65
+ const application = path.join(projectRoot, 'config', 'application.rb');
66
+ const routes = path.join(projectRoot, 'config', 'routes.rb');
67
+ if (!fs.existsSync(application) || !fs.existsSync(routes)) continue;
68
+ try {
69
+ if (!RAILS_APP_RE.test(fs.readFileSync(application, 'utf8'))) continue;
70
+ } catch {
71
+ continue;
72
+ }
73
+ return projectRoot;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ function stripComment(line) {
79
+ let quote = null;
80
+ let escaped = false;
81
+ for (let i = 0; i < line.length; i++) {
82
+ const ch = line[i];
83
+ if (escaped) { escaped = false; continue; }
84
+ if (quote && ch === '\\') { escaped = true; continue; }
85
+ if (quote) { if (ch === quote) quote = null; continue; }
86
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
87
+ if (ch === '#') return line.slice(0, i);
88
+ }
89
+ return line;
90
+ }
91
+
92
+ function bracketDelta(text) {
93
+ let delta = 0;
94
+ let quote = null;
95
+ let escaped = false;
96
+ for (const ch of text) {
97
+ if (escaped) { escaped = false; continue; }
98
+ if (quote && ch === '\\') { escaped = true; continue; }
99
+ if (quote) { if (ch === quote) quote = null; continue; }
100
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
101
+ if ('([{'.includes(ch)) delta++;
102
+ else if (')]}'.includes(ch)) delta--;
103
+ }
104
+ return delta;
105
+ }
106
+
107
+ function logicalStatements(text) {
108
+ const out = [];
109
+ const lines = text.split('\n');
110
+ let pending = '';
111
+ let startLine = 1;
112
+ let depth = 0;
113
+ for (let i = 0; i < lines.length; i++) {
114
+ const code = stripComment(lines[i]).trim();
115
+ if (!pending && !code) continue;
116
+ if (!pending) startLine = i + 1;
117
+ pending += `${pending ? ' ' : ''}${code}`;
118
+ depth += bracketDelta(code);
119
+ if (depth > 0 || /(?:,|=>|\\)\s*$/.test(code)) continue;
120
+ out.push({ text: pending.trim(), line: startLine });
121
+ pending = '';
122
+ depth = 0;
123
+ }
124
+ if (pending) out.push({ text: pending.trim(), line: startLine });
125
+ return out;
126
+ }
127
+
128
+ function firstLiteral(args) {
129
+ const m = args.match(/^\s*(?::([a-zA-Z_]\w*)|["']([^"']+)["'])/);
130
+ return m ? (m[1] ?? m[2]) : null;
131
+ }
132
+
133
+ function optionLiteral(args, name) {
134
+ const re = new RegExp(`(?:\\b${name}\\s*:|:${name}\\s*=>)\\s*(?:"([^"]*)"|'([^']*)'|:([a-zA-Z_]\\w*))`);
135
+ const m = args.match(re);
136
+ return m ? (m[1] ?? m[2] ?? m[3]) : null;
137
+ }
138
+
139
+ function optionSymbols(args, name) {
140
+ const start = args.search(new RegExp(`(?:\\b${name}\\s*:|:${name}\\s*=>)`));
141
+ if (start === -1) return null;
142
+ const tail = args.slice(start).replace(new RegExp(`^(?:${name}\\s*:|:${name}\\s*=>)\\s*`), '');
143
+ const percent = tail.match(/^%i[\[(]([^\])]+)[\])]/);
144
+ if (percent) return percent[1].split(/\s+/).filter(Boolean);
145
+ const array = tail.match(/^\[([^\]]*)\]/);
146
+ if (array) return [...array[1].matchAll(/:([a-zA-Z_]\w*)/g)].map((m) => m[1]);
147
+ const one = tail.match(/^:([a-zA-Z_]\w*)/);
148
+ return one ? [one[1]] : null;
149
+ }
150
+
151
+ function joinRoute(...segments) {
152
+ const joined = segments.filter((s) => s != null && s !== '').join('/').replace(/\/+/g, '/');
153
+ return joined.startsWith('/') ? joined : `/${joined}`;
154
+ }
155
+
156
+ function normalizeRoute(raw) {
157
+ if (!raw) return '/';
158
+ if (raw.includes('*') || /\([^)]*\)/.test(raw) || raw.includes('#{')) return null;
159
+ return joinRoute(raw).replace(/:([a-zA-Z_]\w*)/g, '{$1}').replace(/\/$/, '') || '/';
160
+ }
161
+
162
+ function regularSingular(name) {
163
+ if (/[^a-zA-Z0-9_]/.test(name)) return null;
164
+ if (/ies$/.test(name)) return `${name.slice(0, -3)}y`;
165
+ if (/(?:ches|shes|xes|zes|sses)$/.test(name)) return name.slice(0, -2);
166
+ if (/s$/.test(name) && !/ss$/.test(name)) return name.slice(0, -1);
167
+ return null;
168
+ }
169
+
170
+ function pascal(value) {
171
+ return String(value).split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join('');
172
+ }
173
+
174
+ function operationIdFor(endpoint) {
175
+ const tuple = `${endpoint.verb}\0${endpoint.path}\0${endpoint.controllerPath}#${endpoint.action}`;
176
+ const suffix = crypto.createHash('sha256').update(tuple).digest('hex').slice(0, 10);
177
+ const controller = endpoint.controllerPath.replace(/\//g, '_').replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase();
178
+ return `rails_${endpoint.verb.toLowerCase()}_${controller}_${endpoint.action}_${suffix}`;
179
+ }
180
+
181
+ function controllerClass(controllerPath) {
182
+ return `${controllerPath.split('/').map(pascal).join('::')}Controller`;
183
+ }
184
+
185
+ function controllerFile(projectRoot, controllerPath) {
186
+ return path.join(projectRoot, 'app', 'controllers', `${controllerPath}_controller.rb`);
187
+ }
188
+
189
+ function commonBasePath(paths) {
190
+ if (paths.length === 0) return '/';
191
+ const split = paths.map((p) => p.split('/').filter(Boolean));
192
+ const common = [];
193
+ for (let i = 0; i < Math.min(...split.map((p) => p.length)); i++) {
194
+ if (!split.every((p) => p[i] === split[0][i]) || split[0][i].startsWith('{')) break;
195
+ common.push(split[0][i]);
196
+ }
197
+ return common.length ? `/${common.join('/')}` : '/';
198
+ }
199
+
200
+ function scopedController(raw, modulePrefix) {
201
+ const cleaned = raw.replace(/^\//, '');
202
+ if (!modulePrefix || cleaned.includes('/')) return cleaned;
203
+ return `${modulePrefix}/${cleaned}`;
204
+ }
205
+
206
+ function parseTarget(args, modulePrefix) {
207
+ const m = args.match(/(?:\bto\s*:|=>)\s*["']([^"']+)#([a-zA-Z_]\w*)["']/);
208
+ if (!m) return null;
209
+ return { controllerPath: scopedController(m[1], modulePrefix), action: m[2] };
210
+ }
211
+
212
+ function resourceEndpoints({ singular, actions, collectionPath, memberPath, controllerPath, line, file, label }) {
213
+ const declaration = { rule: `ruby-rails:${singular ? 'resource' : 'resources'}`, line, label };
214
+ const rows = singular
215
+ ? [
216
+ ['new', 'GET', joinRoute(collectionPath, 'new')], ['create', 'POST', collectionPath],
217
+ ['show', 'GET', collectionPath], ['edit', 'GET', joinRoute(collectionPath, 'edit')],
218
+ ['update', 'PATCH', collectionPath], ['update', 'PUT', collectionPath], ['destroy', 'DELETE', collectionPath],
219
+ ]
220
+ : [
221
+ ['index', 'GET', collectionPath], ['create', 'POST', collectionPath],
222
+ ['new', 'GET', joinRoute(collectionPath, 'new')], ['show', 'GET', memberPath],
223
+ ['edit', 'GET', joinRoute(memberPath, 'edit')], ['update', 'PATCH', memberPath],
224
+ ['update', 'PUT', memberPath], ['destroy', 'DELETE', memberPath],
225
+ ];
226
+ return rows.filter(([action]) => actions.has(action)).map(([action, verb, routePath]) => ({
227
+ verb, path: routePath, controllerPath, action, method: action, line, routeFile: file, declaration,
228
+ }));
229
+ }
230
+
231
+ function note(notes, file, line, kind, detail) {
232
+ const key = `${kind}\0${path.basename(file)}`;
233
+ const current = notes.get(key) ?? { kind, file, count: 0, examples: [] };
234
+ current.count++;
235
+ if (current.examples.length < 3) current.examples.push(`${file}:${line}${detail ? ` (${detail})` : ''}`);
236
+ notes.set(key, current);
237
+ }
238
+
239
+ function finalizeNotes(notes) {
240
+ return [...notes.values()].sort((a, b) => a.kind.localeCompare(b.kind) || a.file.localeCompare(b.file)).map((n) =>
241
+ `ruby-rails static scan skipped ${n.count} ${n.kind} declaration(s); examples: ${n.examples.join(', ')}. Re-run with --runtime-routes to ask Rails for the computed route table.`,
242
+ );
243
+ }
244
+
245
+ function parseStaticRouteFile(projectRoot, file) {
246
+ const text = fs.readFileSync(file, 'utf8');
247
+ const endpoints = [];
248
+ const notes = new Map();
249
+ const stack = [{ kind: 'root', pathPrefix: '', modulePrefix: '', dynamic: false }];
250
+ const current = () => stack[stack.length - 1];
251
+
252
+ const addExplicit = (verb, args, line, inlineContext = null) => {
253
+ const ctx = inlineContext ?? current();
254
+ if (ctx.dynamic) { note(notes, file, line, 'dynamic', verb); return; }
255
+ let literal = firstLiteral(args);
256
+ if (!literal) { note(notes, file, line, 'non-literal', verb); return; }
257
+ const target = parseTarget(args, ctx.modulePrefix);
258
+ let controllerPath = target?.controllerPath ?? null;
259
+ let action = target?.action ?? null;
260
+ const on = optionLiteral(args, 'on');
261
+ const resourceCtx = [...stack].reverse().find((s) => s.kind === 'resource');
262
+ const mode = on ?? ctx.routeMode ?? null;
263
+ let base = ctx.pathPrefix;
264
+ if (resourceCtx && (mode === 'member' || mode === 'collection')) {
265
+ base = mode === 'member' ? resourceCtx.memberPath : resourceCtx.collectionPath;
266
+ controllerPath ??= resourceCtx.controllerPath;
267
+ action ??= literal.replace(/^\//, '').split('/').at(-1).replace(/[^a-zA-Z0-9_]/g, '_');
268
+ }
269
+ if (!controllerPath || !action) { note(notes, file, line, 'implicit-target', `${verb} ${literal}`); return; }
270
+ const routePath = normalizeRoute(joinRoute(base, literal));
271
+ if (!routePath) { note(notes, file, line, 'dynamic-path', `${verb} ${literal}`); return; }
272
+ endpoints.push({ verb: verb.toUpperCase(), path: routePath, controllerPath, action, method: action, line, routeFile: file });
273
+ };
274
+
275
+ for (const statement of logicalStatements(text)) {
276
+ let src = statement.text;
277
+ if (!src) continue;
278
+ if (/^end\b/.test(src)) { if (stack.length > 1) stack.pop(); continue; }
279
+
280
+ const inline = src.match(/^(member|collection)\s*\{\s*(.+)\s*\}\s*$/);
281
+ if (inline) {
282
+ const inner = inline[2].match(/^(get|post|put|patch|delete)\b\s*(.*)$/);
283
+ if (inner) addExplicit(inner[1], inner[2], statement.line, { ...current(), routeMode: inline[1] });
284
+ else note(notes, file, statement.line, 'unsupported-inline-block', inline[1]);
285
+ continue;
286
+ }
287
+
288
+ const dynamic = /^(?:if|unless|while|until|for)\b/.test(src) || /\.each\b[\s\S]*\bdo\s*$/.test(src);
289
+ if (dynamic) { stack.push({ ...current(), kind: 'dynamic', dynamic: true }); continue; }
290
+ if (/\.routes\.draw\s+do\s*$/.test(src)) { stack.push({ ...current(), kind: 'draw' }); continue; }
291
+ if (/^(?:concern|concerns|draw|mount|direct|resolve|match)\b/.test(src)) {
292
+ note(notes, file, statement.line, 'unsupported-dsl', src.split(/\s/)[0]);
293
+ if (/\bdo\s*$/.test(src)) stack.push({ ...current(), kind: 'unsupported', dynamic: true });
294
+ continue;
295
+ }
296
+
297
+ const namespace = src.match(/^namespace\b\s*(.*?)(?:\s+do)?$/);
298
+ if (namespace && /\bdo\s*$/.test(src)) {
299
+ const name = firstLiteral(namespace[1]);
300
+ if (!name) { note(notes, file, statement.line, 'non-literal-namespace', 'namespace'); stack.push({ ...current(), kind: 'unsupported', dynamic: true }); continue; }
301
+ stack.push({ ...current(), kind: 'namespace', pathPrefix: joinRoute(current().pathPrefix, name), modulePrefix: [current().modulePrefix, name].filter(Boolean).join('/') });
302
+ continue;
303
+ }
304
+
305
+ const scope = src.match(/^scope\b\s*(.*?)(?:\s+do)?$/);
306
+ if (scope && /\bdo\s*$/.test(src)) {
307
+ const first = firstLiteral(scope[1]);
308
+ const scopePath = optionLiteral(scope[1], 'path') ?? first;
309
+ const scopeModule = optionLiteral(scope[1], 'module');
310
+ if ((scopePath && scopePath.includes('#{')) || /\bshallow\s*:\s*true/.test(scope[1])) {
311
+ note(notes, file, statement.line, 'dynamic-scope', 'scope');
312
+ stack.push({ ...current(), kind: 'unsupported', dynamic: true });
313
+ continue;
314
+ }
315
+ stack.push({ ...current(), kind: 'scope', pathPrefix: scopePath && scopePath !== 'nil' ? joinRoute(current().pathPrefix, scopePath) : current().pathPrefix, modulePrefix: scopeModule ? [current().modulePrefix, scopeModule].filter(Boolean).join('/') : current().modulePrefix });
316
+ continue;
317
+ }
318
+
319
+ const routeMode = src.match(/^(member|collection)\b[\s\S]*\bdo\s*$/);
320
+ if (routeMode) { stack.push({ ...current(), kind: routeMode[1], routeMode: routeMode[1] }); continue; }
321
+
322
+ const resource = src.match(/^(resources|resource)\b\s*(.*)$/);
323
+ if (resource) {
324
+ if (current().dynamic) { note(notes, file, statement.line, 'dynamic', resource[1]); continue; }
325
+ const singular = resource[1] === 'resource';
326
+ const args = resource[2].replace(/\s+do\s*$/, '');
327
+ const name = firstLiteral(args);
328
+ if (!name) { note(notes, file, statement.line, 'non-literal', resource[1]); continue; }
329
+ if (/\bshallow\s*:\s*true/.test(args) || /\bconcerns?\s*:/.test(args)) {
330
+ note(notes, file, statement.line, 'unsupported-resource-option', name);
331
+ if (/\bdo\s*$/.test(src)) stack.push({ ...current(), kind: 'unsupported', dynamic: true });
332
+ continue;
333
+ }
334
+ const parent = [...stack].reverse().find((s) => s.kind === 'resource');
335
+ if (parent && !parent.nestedPath) {
336
+ note(notes, file, statement.line, 'ambiguous-nested-resource', name);
337
+ if (/\bdo\s*$/.test(src)) stack.push({ ...current(), kind: 'unsupported', dynamic: true });
338
+ continue;
339
+ }
340
+ const parentBase = parent ? parent.nestedPath : current().pathPrefix;
341
+ const pathSegment = optionLiteral(args, 'path') ?? name;
342
+ const collectionPath = normalizeRoute(joinRoute(parentBase, pathSegment));
343
+ if (!collectionPath) { note(notes, file, statement.line, 'dynamic-path', name); continue; }
344
+ const paramName = optionLiteral(args, 'param') ?? 'id';
345
+ const memberPath = singular ? collectionPath : joinRoute(collectionPath, `{${paramName}}`);
346
+ const controllerName = optionLiteral(args, 'controller') ?? name;
347
+ const controllerPath = scopedController(controllerName, current().modulePrefix);
348
+ const only = optionSymbols(args, 'only');
349
+ const except = optionSymbols(args, 'except');
350
+ const actions = new Set(only ?? RESOURCE_ACTIONS);
351
+ for (const action of except ?? []) actions.delete(action);
352
+ const invalid = [...actions].filter((a) => !RESOURCE_ACTIONS.includes(a));
353
+ if (invalid.length) { note(notes, file, statement.line, 'unknown-resource-action', invalid.join(',')); for (const a of invalid) actions.delete(a); }
354
+ endpoints.push(...resourceEndpoints({ singular, actions, collectionPath, memberPath, controllerPath, line: statement.line, file, label: `${resource[1]} :${name}` }));
355
+ if (/\bdo\s*$/.test(src)) {
356
+ const parentParam = optionLiteral(args, 'param') ?? regularSingular(name);
357
+ const nestedPath = singular ? collectionPath : (parentParam ? joinRoute(collectionPath, `{${parentParam}_id}`) : null);
358
+ stack.push({ ...current(), kind: 'resource', collectionPath, memberPath, nestedPath, controllerPath, resourceName: name });
359
+ }
360
+ continue;
361
+ }
362
+
363
+ const explicit = src.match(/^(get|post|put|patch|delete)\b\s*(.*)$/);
364
+ if (explicit) { addExplicit(explicit[1], explicit[2], statement.line); continue; }
365
+ const root = src.match(/^root\b\s*(.*)$/);
366
+ if (root) {
367
+ const target = parseTarget(root[1], current().modulePrefix);
368
+ if (!target) note(notes, file, statement.line, 'implicit-target', 'root');
369
+ else endpoints.push({ verb: 'GET', path: joinRoute(current().pathPrefix), controllerPath: target.controllerPath, action: target.action, method: target.action, line: statement.line, routeFile: file });
370
+ continue;
371
+ }
372
+ if (/\bdo\s*$/.test(src)) stack.push({ ...current(), kind: 'unknown', dynamic: true });
373
+ }
374
+
375
+ return { endpoints, notes: finalizeNotes(notes) };
376
+ }
377
+
378
+ function parseExpandedRoutes(output, projectRoot) {
379
+ const routes = [];
380
+ for (const block of output.split(/(?=--\[ Route \d+ \]-+)/)) {
381
+ const field = (name) => block.match(new RegExp(`^\\s*${name}\\s*\\|\\s*(.*)$`, 'm'))?.[1]?.trim() ?? '';
382
+ const verbs = field('Verb').split('|').map((v) => v.trim().toUpperCase()).filter(Boolean);
383
+ const rawUri = field('URI').replace(/\(\.:format\)$/, '');
384
+ const target = field('Controller#Action').match(/^([^\s#]+)#([a-zA-Z_]\w*)/);
385
+ if (!target || verbs.length === 0) continue;
386
+ const routePath = normalizeRoute(rawUri);
387
+ if (!routePath) continue;
388
+ const controllerPath = target[1];
389
+ if (!fs.existsSync(controllerFile(projectRoot, controllerPath))) continue;
390
+ const source = field('Source Location');
391
+ const sourceMatch = source.match(/^(.*?):(\d+)$/);
392
+ for (const verb of verbs) {
393
+ if (!HTTP_VERBS.has(verb.toLowerCase())) continue;
394
+ routes.push({
395
+ verb, path: routePath, controllerPath, action: target[2], method: target[2],
396
+ line: sourceMatch ? Number(sourceMatch[2]) : null,
397
+ routeFile: sourceMatch && path.isAbsolute(sourceMatch[1]) ? sourceMatch[1] : path.join(projectRoot, 'config', 'routes.rb'),
398
+ });
399
+ }
400
+ }
401
+ return routes;
402
+ }
403
+
404
+ export function introspectRailsRoutes(repoRoot, projectRoot) {
405
+ const binRails = path.join(projectRoot, 'bin', 'rails');
406
+ if (!fs.existsSync(binRails)) {
407
+ const err = new Error(`--runtime-routes requires ${path.relative(repoRoot, binRails)}; no Rails binstub was found`);
408
+ err.code = 'RUNTIME_ROUTES_FAILED';
409
+ throw err;
410
+ }
411
+ let stdout;
412
+ try {
413
+ stdout = execFileSync(binRails, ['routes', '--expanded'], {
414
+ cwd: projectRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024,
415
+ env: process.env, stdio: ['ignore', 'pipe', 'pipe'],
416
+ });
417
+ } catch (cause) {
418
+ const detail = cause.signal === 'SIGTERM' ? 'timed out after 60 seconds' : (cause.stderr?.toString().trim() || cause.message);
419
+ const err = new Error(`Rails runtime route introspection failed: ${detail}`);
420
+ err.code = 'RUNTIME_ROUTES_FAILED';
421
+ throw err;
422
+ }
423
+ const endpoints = parseExpandedRoutes(stdout, projectRoot);
424
+ if (endpoints.length === 0) {
425
+ const err = new Error('Rails runtime route introspection returned no local controller routes; refusing to replace the static route set with an empty parse');
426
+ err.code = 'RUNTIME_ROUTES_FAILED';
427
+ throw err;
428
+ }
429
+ return {
430
+ endpoints,
431
+ metadata: {
432
+ kind: 'rails-routes', command: ['bin/rails', 'routes', '--expanded'],
433
+ project_root: path.relative(repoRoot, projectRoot) || '.', rails_env: process.env.RAILS_ENV || 'development', status: 'used',
434
+ },
435
+ };
436
+ }
437
+
438
+ function extractEntities(projectRoot) {
439
+ const entities = [];
440
+ for (const file of rubyFilesUnder(projectRoot, ['app/models'])) {
441
+ const text = fs.readFileSync(file, 'utf8');
442
+ for (const match of text.matchAll(/^\s*class\s+([A-Z]\w*(?:::[A-Z]\w*)*)\s*<\s*(?:ApplicationRecord|ActiveRecord::Base)\b/gm)) {
443
+ if (match[1] === 'ApplicationRecord') continue; // abstract Rails base, never a resource entity
444
+ const after = text.slice(match.index, text.indexOf('\nend', match.index) === -1 ? text.length : text.indexOf('\nend', match.index));
445
+ const table = after.match(/\bself\.table_name\s*=\s*["']([^"']+)["']/)?.[1] ?? null;
446
+ const idField = after.match(/\bself\.primary_key\s*=\s*["']([^"']+)["']/)?.[1] ?? null;
447
+ entities.push({ className: match[1], table, tableSource: table ? 'explicit' : null, idField, idFieldIsUuid: null, file, line: lineNumberAt(text, match.index) });
448
+ }
449
+ }
450
+ return entities;
451
+ }
452
+
453
+ function endpointKey(ep) {
454
+ return `${ep.verb}\0${ep.path}\0${ep.controllerPath}\0${ep.action}`;
455
+ }
456
+
457
+ function buildModules(projectRoot, rawEndpoints, entities, notes) {
458
+ const modules = new Map();
459
+ const entry = (name) => {
460
+ if (!modules.has(name)) modules.set(name, { module: name, controllers: [], entities: [], enums: [], dtos: [] });
461
+ return modules.get(name);
462
+ };
463
+ const byController = new Map();
464
+ const seen = new Set();
465
+ for (const raw of rawEndpoints) {
466
+ const file = controllerFile(projectRoot, raw.controllerPath);
467
+ if (!fs.existsSync(file)) {
468
+ notes.push(`ruby-rails skipped ${raw.verb} ${raw.path}: local controller file ${path.relative(projectRoot, file)} was not found.`);
469
+ continue;
470
+ }
471
+ const key = endpointKey(raw);
472
+ if (seen.has(key)) continue;
473
+ seen.add(key);
474
+ if (!byController.has(raw.controllerPath)) byController.set(raw.controllerPath, []);
475
+ byController.get(raw.controllerPath).push(raw);
476
+ }
477
+
478
+ for (const [controllerPath, rows] of [...byController.entries()].sort(([a], [b]) => a.localeCompare(b))) {
479
+ rows.sort((a, b) => a.path.localeCompare(b.path) || a.verb.localeCompare(b.verb) || a.action.localeCompare(b.action));
480
+ const declarations = [];
481
+ const declarationIndexes = new Map();
482
+ const endpoints = rows.map((row) => {
483
+ let declarationIndex;
484
+ if (row.declaration) {
485
+ const dKey = `${row.routeFile}\0${row.declaration.line}\0${row.declaration.rule}`;
486
+ if (!declarationIndexes.has(dKey)) { declarationIndexes.set(dKey, declarations.length); declarations.push(row.declaration); }
487
+ declarationIndex = declarationIndexes.get(dKey);
488
+ }
489
+ return {
490
+ verb: row.verb, path: row.path, operationId: operationIdFor(row), operationIdSource: 'bskel-synthesized',
491
+ method: row.method, line: row.line,
492
+ ...(declarationIndex !== undefined ? { declarationIndex } : {}),
493
+ };
494
+ });
495
+ const leaf = controllerPath.split('/').at(-1).replace(/_controller$/, '');
496
+ entry(leaf).controllers.push({
497
+ className: controllerClass(controllerPath), basePath: commonBasePath(endpoints.map((e) => e.path)),
498
+ operationIds: [], endpoints, file: controllerFile(projectRoot, controllerPath),
499
+ ...(declarations.length ? { declarations } : {}),
500
+ });
501
+ }
502
+
503
+ for (const entity of entities) {
504
+ const leaf = entity.className.split('::').at(-1).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
505
+ const target = [...modules.keys()].find((name) => name === leaf || regularSingular(name) === leaf);
506
+ entry(target ?? '_models').entities.push(entity);
507
+ }
508
+ return [...modules.values()].sort((a, b) => a.module.localeCompare(b.module));
509
+ }
510
+
511
+ export function scanRubyRails(repoRoot, projectRoot, { runtimeRoutes = null } = {}) {
512
+ const discoveredRouteFiles = routeFiles(projectRoot);
513
+ const staticEndpoints = [];
514
+ const scanNotes = [];
515
+ for (const file of discoveredRouteFiles) {
516
+ const parsed = parseStaticRouteFile(projectRoot, file);
517
+ staticEndpoints.push(...parsed.endpoints);
518
+ scanNotes.push(...parsed.notes);
519
+ }
520
+
521
+ let endpoints = staticEndpoints;
522
+ if (runtimeRoutes) {
523
+ const staticByKey = new Map(staticEndpoints.map((ep) => [endpointKey(ep), ep]));
524
+ endpoints = runtimeRoutes.endpoints.map((ep) => {
525
+ const source = staticByKey.get(endpointKey(ep));
526
+ return source?.declaration ? { ...ep, declaration: source.declaration } : ep;
527
+ });
528
+ scanNotes.push('Rails runtime route introspection is point-in-time and environment-dependent; changes in untracked environment variables cannot be represented in the scan gate input hash.');
529
+ }
530
+
531
+ const entities = extractEntities(projectRoot);
532
+ const filesRead = railsReadFiles(projectRoot)
533
+ .map((f) => path.relative(repoRoot, f));
534
+ return {
535
+ modules: buildModules(projectRoot, endpoints, entities, scanNotes),
536
+ filesRead,
537
+ scanNotes,
538
+ apiSurfaceSource: runtimeRoutes
539
+ ? 'Rails-computed route table from explicit `bin/rails routes --expanded` runtime introspection; operation ids are deterministic bskel identifiers unless reconciled with --openapi-file'
540
+ : 'conservative static analysis of literal Rails routes.rb DSL only; dynamic declarations are listed in unknowns and operation ids are deterministic bskel identifiers',
541
+ ...(runtimeRoutes ? { runtimeIntrospection: runtimeRoutes.metadata } : {}),
542
+ };
543
+ }
544
+
545
+ export const adapter = {
546
+ contract: 'sbf.adapter/2',
547
+ id: 'ruby-rails',
548
+ title: 'Ruby / Rails',
549
+ specificity: 95,
550
+ confidence: 'high',
551
+ verificationBasis: 'production-repo',
552
+ capabilities: {
553
+ 'api.operations': true,
554
+ 'api.request-shape': false,
555
+ 'resource.fetch': false,
556
+ 'codegen.handles': false,
557
+ },
558
+ detect: detectRubyRailsRoot,
559
+ scan(repoRoot, projectRoot, options) {
560
+ return scanRubyRails(repoRoot, projectRoot, options);
561
+ },
562
+ introspectRoutes: introspectRailsRoutes,
563
+ listReadSet(repoRoot) {
564
+ const root = detectRubyRailsRoot(repoRoot);
565
+ if (!root) return [];
566
+ return railsReadFiles(root)
567
+ .map((f) => path.relative(repoRoot, f));
568
+ },
569
+ diagnostics(repoRoot) {
570
+ const messages = [];
571
+ const root = detectRubyRailsRoot(repoRoot);
572
+ if (!root) messages.push({ level: 'info', code: 'rails-app-not-detected', message: 'no project combined a Rails dependency, config/application.rb < Rails::Application, and config/routes.rb' });
573
+ if (!binaryAvailable('rg')) messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter cannot discover Rails project/source files without it and will degrade to generic-grep' });
574
+ messages.push({ level: 'info', code: 'runtime-routes-hint', message: '`bskel scan --runtime-routes` runs `bin/rails routes --expanded` and therefore boots the target application and its initializers; use it only for a trusted repo when static unknowns matter.' });
575
+ messages.push({ level: 'info', code: 'openapi-extraction-hint', message: 'Rails has no framework-native OpenAPI generator. `contract emit` works with deterministic bskel operation ids; pass an application-generated --openapi-file when the project uses rswag or another OpenAPI integration and you need source-backed schemas/security.' });
576
+ return messages;
577
+ },
578
+ };
@@ -6,7 +6,7 @@
6
6
  // D-adapter-registry in DECISIONS.md.
7
7
  export const CAPABILITIES = Object.freeze({
8
8
  'api.operations': {
9
- summary: 'endpoints carry a source-pinned, non-null operationId',
9
+ summary: 'endpoints carry a stable, non-null operationId (source-pinned or explicitly marked as bskel-synthesized)',
10
10
  why: 'a contract operation must be addressable by id -- generic-grep\'s route-pattern grep never correlates one (operationId is always null by construction, see D-generic-grep-reconnaissance in DECISIONS.md)',
11
11
  },
12
12
  'api.request-shape': {
@@ -19,7 +19,7 @@ export const CAPABILITIES = Object.freeze({
19
19
  },
20
20
  'codegen.handles': {
21
21
  summary: 'a handle codegen provider exists for this adapter\'s stack',
22
- why: 'two providers exist today (java-spring, python-fastapi) -- see D-handles-providers (G4) in DECISIONS.md; a stack without one still fails this capability honestly rather than pretending',
22
+ why: 'three providers exist today (java-spring, python-fastapi, typescript-express) -- see D-handles-providers (G4) and D-typescript-express-provider in DECISIONS.md; a stack without one still fails this capability honestly rather than pretending',
23
23
  },
24
24
  });
25
25
 
@@ -187,7 +187,7 @@ export function computeDbDrift(liveTables, relatedModules) {
187
187
  // -- bin/bskel.mjs computes it once via lib/doctor.mjs's binaryAvailable('rg') and hands it in as
188
188
  // plain data. Defaults to `true` so every existing call site (this whole test suite included)
189
189
  // stays byte-for-byte unchanged.
190
- export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, adapters = ADAPTERS, rgAvailable = true }) {
190
+ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, adapters = ADAPTERS, rgAvailable = true, runtimeRoutes = false }) {
191
191
  const detections = adapters
192
192
  .map((a) => ({ a, d: a.detect(repoRoot) }))
193
193
  .filter(({ d }) => d != null)
@@ -213,7 +213,16 @@ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, a
213
213
  }
214
214
 
215
215
  const { a: chosen, d: detection } = detections[0];
216
- const result = chosen.scan(repoRoot, detection);
216
+ let runtimeRouteSnapshot = null;
217
+ if (runtimeRoutes) {
218
+ if (typeof chosen.introspectRoutes !== 'function') {
219
+ const err = new Error(`--runtime-routes is not supported by the detected ${chosen.id} adapter`);
220
+ err.code = 'RUNTIME_ROUTES_UNSUPPORTED';
221
+ throw err;
222
+ }
223
+ runtimeRouteSnapshot = chosen.introspectRoutes(repoRoot, detection);
224
+ }
225
+ const result = chosen.scan(repoRoot, detection, { runtimeRoutes: runtimeRouteSnapshot });
217
226
  const adapter = chosen.id;
218
227
  const confidence = chosen.confidence;
219
228
  const modules = result.modules;
@@ -222,6 +231,8 @@ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, a
222
231
  // unknowns[] -- optional (`?? []`) so an adapter that doesn't populate it degrades to "nothing
223
232
  // to report" rather than throwing, the same discipline apiSurfaceSource/filesRead already use.
224
233
  const repositoryResourceNotes = result.repositoryResourceNotes ?? [];
234
+ const scanNotes = result.scanNotes ?? [];
235
+ const runtimeIntrospection = result.runtimeIntrospection ?? null;
225
236
  const apiSurfaceSource = result.apiSurfaceSource ?? DEFAULT_API_SURFACE_SOURCE;
226
237
  // S2 (D-gate-precision, continued): the adapter's own real read-set, persisted so
227
238
  // lib/gate-definitions.mjs's `scan` gate can hash it for a precise staleness token instead of
@@ -311,6 +322,9 @@ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, a
311
322
  if (repositoryResourceNotes.length > 0) {
312
323
  unknowns.push(...repositoryResourceNotes);
313
324
  }
325
+ if (scanNotes.length > 0) {
326
+ unknowns.push(...scanNotes);
327
+ }
314
328
  // A1 §7: this scan can't correct a global path prefix (only --openapi-file's real-document
315
329
  // reconciliation can, see D-openapi-reconciliation) -- but it CAN tell a user who doesn't know
316
330
  // that flag exists that the defect is likely present, before they ever emit a wrong contract.
@@ -335,6 +349,7 @@ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, a
335
349
  verdict,
336
350
  rg_available: rgAvailable,
337
351
  path_prefix_signals: pathPrefixSignals,
352
+ ...(runtimeIntrospection ? { runtime_introspection: runtimeIntrospection } : {}),
338
353
  related_modules: relatedModules,
339
354
  collisions,
340
355
  unknowns,
@@ -81,7 +81,7 @@ async function loadOneAdapter(file, schema) {
81
81
 
82
82
  // Validate only the JSON-shaped fields -- detect/scan/diagnostics/listReadSet are functions,
83
83
  // which JSON Schema has no vocabulary for; checked separately below.
84
- const { detect, scan, diagnostics, listReadSet, ...data } = descriptor;
84
+ const { detect, scan, diagnostics, listReadSet, introspectRoutes, ...data } = descriptor;
85
85
  const validateFn = ajv().getSchema(schema.$id) ?? ajv().compile(schema);
86
86
  if (!validateFn(data)) {
87
87
  const details = (validateFn.errors ?? []).map((e) => `${e.instancePath || '(root)'} ${e.message}`).join('; ');
@@ -101,6 +101,9 @@ async function loadOneAdapter(file, schema) {
101
101
  if (descriptor.listReadSet !== undefined && typeof descriptor.listReadSet !== 'function') {
102
102
  return { error: { file, message: 'adapter.listReadSet, if present, must be a function' } };
103
103
  }
104
+ if (descriptor.introspectRoutes !== undefined && typeof descriptor.introspectRoutes !== 'function') {
105
+ return { error: { file, message: 'adapter.introspectRoutes, if present, must be a function' } };
106
+ }
104
107
  return { adapter: descriptor };
105
108
  }
106
109