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.
- package/README.md +13 -3
- package/bin/bskel.mjs +4 -2
- package/contracts/emit.mjs +9 -2
- package/contracts/openapi.mjs +11 -7
- package/lib/cli.mjs +2 -1
- package/package.json +3 -2
- package/scanners/adapters/javascript-express.mjs +133 -83
- package/scanners/adapters/ruby-rails.mjs +578 -0
- package/scanners/capabilities.mjs +2 -2
- package/scanners/index.mjs +17 -2
- package/scanners/registry.mjs +4 -1
- package/scanners/render.mjs +3 -0
- package/scanners/text-util.mjs +5 -2
- package/schemas/oracle-manifest.schema.json +1 -1
- package/schemas/scan-report.schema.json +12 -0
|
@@ -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
|
|
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: '
|
|
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
|
|
package/scanners/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/scanners/registry.mjs
CHANGED
|
@@ -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
|
|