backend-skeleton 1.7.1 → 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 CHANGED
@@ -105,6 +105,10 @@ bskel scan # zero flags: every module/controller/entity/e
105
105
  # files written, no gate touched
106
106
  ```
107
107
 
108
+ Rails projects are scanned statically by default. On a trusted Rails checkout, add
109
+ `--runtime-routes` to boot the application and use `bin/rails routes --expanded` as the route
110
+ source; initializers and application boot code will run.
111
+
108
112
  That's a read-only look, not the gated workflow — for real feature work (collision-checked against
109
113
  a specific idea, contract-gated, codegen), see below.
110
114
 
@@ -595,6 +599,11 @@ string for anything missing.
595
599
  `D-adapter-registry` in `DECISIONS.md`):
596
600
  - `java-spring` — Spring Boot (`build.gradle`/`pom.xml` + `src/main/java`). Full capability set:
597
601
  operation extraction, request-body detection, and a real codegen provider for `handles emit`.
602
+ - `ruby-rails` — Rails 8 (`Gemfile` + `config/application.rb` + `config/routes.rb`). Statically
603
+ expands conventional routes/resources and extracts ActiveRecord table/primary-key metadata.
604
+ Operation ids are deterministic bskel syntheses, not source declarations; `--runtime-routes`
605
+ explicitly boots the trusted application for authoritative framework routing. Scanner only:
606
+ request-shape extraction and handles codegen are not supported.
598
607
  - `python-fastapi` — FastAPI + SQLModel. Real codegen provider for `handles emit`; contract-grade
599
608
  operation extraction is not supported (FastAPI generates operation ids at runtime) — pass a real
600
609
  OpenAPI document via `--openapi-file` for a trustworthy contract.
package/bin/bskel.mjs CHANGED
@@ -98,7 +98,7 @@ function usage() {
98
98
  bskel pattern show <pattern_id> --pattern-database-url-env <NAME> [--json]
99
99
  bskel pattern suggest --stack spring|fastapi --pattern-database-url-env <NAME> [--json]
100
100
  bskel preflight [--max-behind N] [--offline|--no-fetch] [--allow-dirty] [--max-age-minutes N] [--fetch-timeout-seconds N] [--json]
101
- bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]
101
+ bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--runtime-routes] [--db [--database-url-env <NAME>] [--schema public]]
102
102
  bskel scan disposition --feature <id> --mode reuse|extend|replace|parallel [--module <name>] [--note "..."] [--breaking-approved]
103
103
  bskel scan explain <module> --feature <id> [--json]
104
104
  bskel scan repair --feature <id> [--json]
@@ -711,8 +711,10 @@ async function cmdScan(args) {
711
711
  }
712
712
  let report;
713
713
  try {
714
- report = runScan({ repoRoot: root, terms, includeDb: flags.db, dbSchema, rgAvailable });
714
+ report = runScan({ repoRoot: root, terms, includeDb: flags.db, dbSchema, rgAvailable, runtimeRoutes: flags['runtime-routes'] });
715
715
  } catch (err) {
716
+ if (err.code === 'RUNTIME_ROUTES_UNSUPPORTED') fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
717
+ if (err.code === 'RUNTIME_ROUTES_FAILED') fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', err.message);
716
718
  // Unreachable with the two shipped adapters (generic-grep's specificity-0 detect() is
717
719
  // unconditional) -- becomes reachable the moment a future adapter's detect() is
718
720
  // conditional, or two adapters tie at the same specificity. See scanners/index.mjs.
@@ -135,7 +135,7 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
135
135
  let operationId = ep.operationId;
136
136
  let verb = ep.verb;
137
137
  let route = ep.path;
138
- let provenance = 'scan';
138
+ let provenance = ep.operationIdSource === 'bskel-synthesized' ? 'scan-synthesized' : 'scan';
139
139
  let openapiAttempted = false;
140
140
  let openapiReason = null;
141
141
  // A2/A3: only ever set for matched/adopted (contracts/openapi.mjs's applyRequestBodySchema/
@@ -225,7 +225,7 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
225
225
  descriptionUnresolvedReason = res.descriptionUnresolvedReason ?? null;
226
226
  warnings.push(makeWarning('CONTRACT_OPENAPI_DERIVED_OPERATION_ID', {
227
227
  subject: operationId,
228
- message: `operationId "${operationId}" for ${res.verb} ${res.path} was not found in the source (no @Operation(operationId=...)) -- adopted directly from the OpenAPI document instead`,
228
+ message: `operationId "${operationId}" for ${res.verb} ${res.path} was not source-pinned by the scanner -- adopted directly from the OpenAPI document instead`,
229
229
  detail: { verb: res.verb, path: res.path, scan_verb: ep.verb, scan_path: ep.path },
230
230
  }));
231
231
  break;
@@ -263,6 +263,13 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
263
263
  // recording that OpenAPI reconciliation was attempted and why it didn't help.
264
264
  openapiAttempted = true;
265
265
  openapiReason = res.reason;
266
+ if (ep.operationIdSource === 'bskel-synthesized') {
267
+ warnings.push(makeWarning('CONTRACT_OPENAPI_MISSING_OPERATION', {
268
+ subject: ep.operationId,
269
+ message: `bskel-synthesized operationId "${ep.operationId}" (${ep.verb} ${ep.path}) could not be reconciled to a unique OpenAPI operation (${res.reason}) -- keeping the synthesized id and scan path`,
270
+ detail: { verb: ep.verb, path: ep.path, reason: res.reason },
271
+ }));
272
+ }
266
273
  break;
267
274
  default:
268
275
  break;
@@ -1768,7 +1768,7 @@ export function reconcileModule({ index, module, pathPrefix = null, includeDescr
1768
1768
  const anchorDeltas = [];
1769
1769
  for (const controller of module.controllers) {
1770
1770
  for (const ep of controller.endpoints) {
1771
- if (!ep.operationId) continue;
1771
+ if (!ep.operationId || ep.operationIdSource === 'bskel-synthesized') continue;
1772
1772
  const docEntry = index.byOperationId.get(ep.operationId);
1773
1773
  if (!docEntry || docEntry.verb !== ep.verb) continue; // verb mismatch => not a safe anchor, surfaces as drift below
1774
1774
  const delta = computeDelta(ep.path, docEntry.path);
@@ -1828,7 +1828,7 @@ export function reconcileModule({ index, module, pathPrefix = null, includeDescr
1828
1828
  const key = endpointKey(ci, ei);
1829
1829
  let result;
1830
1830
 
1831
- if (ep.operationId) {
1831
+ if (ep.operationId && ep.operationIdSource !== 'bskel-synthesized') {
1832
1832
  const docEntry = index.byOperationId.get(ep.operationId);
1833
1833
  if (!docEntry) {
1834
1834
  result = { kind: 'missing', scanVerb: ep.verb, scanPath: ep.path };
@@ -1868,14 +1868,18 @@ export function reconcileModule({ index, module, pathPrefix = null, includeDescr
1868
1868
  stats.drift++;
1869
1869
  }
1870
1870
  }
1871
- } else if (prefix.value == null) {
1872
- result = { kind: 'unresolved', reason: 'prefix-inconclusive', scanVerb: ep.verb, scanPath: ep.path };
1873
- stats.unresolved++;
1874
1871
  } else {
1875
- const candidates = prefix.value === '' ? [ep.path] : [...new Set([prefix.value + ep.path, ep.path])];
1872
+ // An explicitly bskel-synthesized operation id is useful when no source document
1873
+ // exists, but it must never be treated as if an OpenAPI producer authored the same
1874
+ // identifier. Reconcile it exactly like an unpinned endpoint: route first, then adopt
1875
+ // the document's own operationId. Exact-path matching remains safe even when no
1876
+ // prefix could be inferred; a caller only needs --path-prefix when exact matching fails.
1877
+ const candidates = prefix.value == null
1878
+ ? [ep.path]
1879
+ : prefix.value === '' ? [ep.path] : [...new Set([prefix.value + ep.path, ep.path])];
1876
1880
  const hits = candidates.flatMap((c) => index.byRoute.get(`${ep.verb} ${canonicalRouteShape(c)}`) ?? []);
1877
1881
  if (hits.length === 0) {
1878
- result = { kind: 'unresolved', reason: 'no-candidate', scanVerb: ep.verb, scanPath: ep.path };
1882
+ result = { kind: 'unresolved', reason: prefix.value == null ? 'prefix-inconclusive' : 'no-candidate', scanVerb: ep.verb, scanPath: ep.path };
1879
1883
  stats.unresolved++;
1880
1884
  } else if (hits.length === 1 && hits[0].operationId) {
1881
1885
  result = {
package/lib/cli.mjs CHANGED
@@ -120,7 +120,7 @@ export const COMMANDS = {
120
120
  },
121
121
  },
122
122
  scan: {
123
- usage: 'bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]',
123
+ usage: 'bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--runtime-routes] [--db [--database-url-env <NAME>] [--schema public]]',
124
124
  options: {
125
125
  feature: { type: 'string', default: null },
126
126
  terms: { type: 'string', default: '' },
@@ -132,6 +132,7 @@ export const COMMANDS = {
132
132
  schema: { type: 'string', default: 'public' },
133
133
  json: { type: 'boolean', default: false },
134
134
  'accept-low-confidence': { type: 'boolean', default: false },
135
+ 'runtime-routes': { type: 'boolean', default: false },
135
136
  },
136
137
  },
137
138
  'scan disposition': {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
- "description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
5
+ "description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scans Spring, Rails, FastAPI, and Express; scaffolding codegen included for selected stacks.",
6
6
  "license": "AGPL-3.0-or-later",
7
7
  "repository": {
8
8
  "type": "git",
@@ -49,6 +49,7 @@
49
49
  "test:java-ast": "node scripts/java-ast-smoke.mjs",
50
50
  "test:python-integration": "node scripts/python-integration-smoke.mjs",
51
51
  "test:typescript-compile": "node scripts/typescript-typecheck-smoke.mjs",
52
+ "test:rails-integration": "node scripts/rails-integration-smoke.mjs",
52
53
  "test:spring-initializr-canary": "node scripts/spring-initializr-canary.mjs",
53
54
  "test:shadow-validation": "node scripts/shadow-validation-smoke.mjs",
54
55
  "test:oracle-corpus": "node scripts/shadow-validation-smoke.mjs --manifest test/fixtures/oracle-manifest.json",
@@ -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
 
@@ -47,6 +47,9 @@ export function renderScanMarkdown(report) {
47
47
  lines.push(`**Terms**: ${report.terms.join(', ') || '(none)'}`);
48
48
  lines.push(`**Adapter**: ${report.adapter} (confidence: ${report.confidence})`);
49
49
  lines.push(`**API surface source**: ${report.api_surface_source}`);
50
+ if (report.runtime_introspection) {
51
+ lines.push(`**Runtime routes**: ${report.runtime_introspection.command.join(' ')} (${report.runtime_introspection.rails_env}, project ${report.runtime_introspection.project_root})`);
52
+ }
50
53
  lines.push(`**Verdict**: \`${report.verdict}\``);
51
54
  lines.push('');
52
55
 
@@ -17,9 +17,12 @@ export function lineNumberAt(text, index) {
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import path from 'node:path';
19
19
 
20
- export function listRgFiles(dir, globs, excludeGlobs = []) {
20
+ export function listRgFiles(dir, globs, excludeGlobs = [], execFn = execFileSync) {
21
21
  try {
22
- const out = execFileSync('rg', ['--files', ...globs.flatMap((g) => ['-g', g]), ...excludeGlobs.flatMap((g) => ['-g', g]), dir], { encoding: 'utf8' });
22
+ // A production Rails monorepo (Discourse) emits >1 MiB of paths for `-g '*.rb'`.
23
+ // Node's default execFileSync buffer then throws ENOBUFS, which the intentional "no files"
24
+ // catch below cannot distinguish and used to turn the entire scan into an empty result.
25
+ const out = execFn('rg', ['--files', ...globs.flatMap((g) => ['-g', g]), ...excludeGlobs.flatMap((g) => ['-g', g]), dir], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
23
26
  return out.split('\n').filter(Boolean).sort(); // O6: rg --files order isn't guaranteed.
24
27
  } catch {
25
28
  return []; // rg exits 1 on "no files matched" -- not an error, just nothing to report
@@ -11,7 +11,7 @@
11
11
  "adapters": {
12
12
  "type": "object",
13
13
  "propertyNames": {
14
- "enum": ["java-spring", "python-fastapi", "typescript-express", "javascript-express", "generic-grep"]
14
+ "enum": ["java-spring", "ruby-rails", "python-fastapi", "typescript-express", "javascript-express", "generic-grep"]
15
15
  },
16
16
  "additionalProperties": {
17
17
  "type": "array",
@@ -18,6 +18,18 @@
18
18
  "type": "boolean"
19
19
  },
20
20
  "path_prefix_signals": { "type": "array" },
21
+ "runtime_introspection": {
22
+ "type": "object",
23
+ "additionalProperties": false,
24
+ "required": ["kind", "command", "project_root", "rails_env", "status"],
25
+ "properties": {
26
+ "kind": { "const": "rails-routes" },
27
+ "command": { "type": "array", "items": { "type": "string" } },
28
+ "project_root": { "type": "string" },
29
+ "rails_env": { "type": "string" },
30
+ "status": { "const": "used" }
31
+ }
32
+ },
21
33
  "related_modules": {
22
34
  "type": "array",
23
35
  "items": {