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 +9 -0
- 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/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
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.
|
package/contracts/emit.mjs
CHANGED
|
@@ -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
|
|
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;
|
package/contracts/openapi.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
package/scanners/render.mjs
CHANGED
|
@@ -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
|
|
package/scanners/text-util.mjs
CHANGED
|
@@ -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
|
-
|
|
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": {
|