backend-skeleton 1.0.0-beta.1
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/LICENSE +21 -0
- package/README.md +284 -0
- package/bin/bskel.mjs +2384 -0
- package/contracts/completeness.mjs +176 -0
- package/contracts/emit.mjs +287 -0
- package/contracts/export.mjs +325 -0
- package/contracts/openapi.mjs +869 -0
- package/contracts/validate.mjs +147 -0
- package/handles/_engine.mjs +281 -0
- package/handles/codec.mjs +119 -0
- package/handles/conformance.mjs +74 -0
- package/handles/providers/java-spring/ast-bridge.mjs +59 -0
- package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/handles/providers/java-spring/ast-helper/gradlew +248 -0
- package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
- package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
- package/handles/providers/java-spring/emit.mjs +232 -0
- package/handles/providers/java-spring/patch-strategy.mjs +229 -0
- package/handles/providers/java-spring/plan.mjs +377 -0
- package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
- package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
- package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
- package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
- package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
- package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
- package/handles/providers/java-spring.mjs +21 -0
- package/handles/providers/python-fastapi/emit.mjs +171 -0
- package/handles/providers/python-fastapi/plan.mjs +186 -0
- package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
- package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
- package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
- package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
- package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
- package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
- package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
- package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
- package/handles/providers/python-fastapi.mjs +22 -0
- package/handles/providers/typescript-express/emit.mjs +128 -0
- package/handles/providers/typescript-express/plan.mjs +234 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
- package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
- package/handles/providers/typescript-express.mjs +20 -0
- package/handles/registry.mjs +90 -0
- package/lib/cli.mjs +430 -0
- package/lib/doctor.mjs +200 -0
- package/lib/exit-codes.mjs +67 -0
- package/lib/featureid.mjs +55 -0
- package/lib/featurelifecycle.mjs +205 -0
- package/lib/fsutil.mjs +50 -0
- package/lib/gate-definitions.mjs +293 -0
- package/lib/gates.mjs +263 -0
- package/lib/handles-manifest.mjs +92 -0
- package/lib/lock.mjs +68 -0
- package/lib/patch-approvals.mjs +56 -0
- package/lib/paths.mjs +21 -0
- package/lib/repo.mjs +44 -0
- package/lib/schema-validate.mjs +56 -0
- package/lib/state.mjs +124 -0
- package/lib/template.mjs +35 -0
- package/lib/verify.mjs +206 -0
- package/lib/workflow.mjs +142 -0
- package/new/fastapi.mjs +165 -0
- package/new/index.mjs +62 -0
- package/new/params.mjs +233 -0
- package/new/spring.mjs +198 -0
- package/new/templates/fastapi/README.md +26 -0
- package/new/templates/fastapi/app/__init__.py +0 -0
- package/new/templates/fastapi/app/main.py +8 -0
- package/new/templates/fastapi/gitignore +6 -0
- package/new/templates/fastapi/pyproject.toml +14 -0
- package/package.json +50 -0
- package/scanners/adapters/_express-shared.mjs +238 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
- package/scanners/adapters/generic-grep.mjs +128 -0
- package/scanners/adapters/java-spring.mjs +301 -0
- package/scanners/adapters/javascript-express.mjs +422 -0
- package/scanners/adapters/python-fastapi.mjs +348 -0
- package/scanners/adapters/typescript-express.mjs +299 -0
- package/scanners/capabilities.mjs +90 -0
- package/scanners/conformance.mjs +59 -0
- package/scanners/db/introspect.mjs +109 -0
- package/scanners/db/migrations.mjs +126 -0
- package/scanners/index.mjs +281 -0
- package/scanners/registry.mjs +130 -0
- package/scanners/render.mjs +136 -0
- package/scanners/text-util.mjs +8 -0
- package/schemas/adapter.schema.json +23 -0
- package/schemas/agent-envelope.schema.json +21 -0
- package/schemas/contract-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +78 -0
- package/schemas/feature-index.schema.json +25 -0
- package/schemas/feature.schema.json +17 -0
- package/schemas/gate-event.schema.json +19 -0
- package/schemas/handles-plan.schema.json +31 -0
- package/schemas/handles-provider.schema.json +26 -0
- package/schemas/patch-approvals.schema.json +28 -0
- package/schemas/scan-report.schema.json +102 -0
- package/schemas/stack-choice.schema.json +89 -0
- package/schemas/stack-record.schema.json +20 -0
- package/schemas/state.schema.json +43 -0
- package/scripts/preflight-base-ref.sh +226 -0
- package/stack/apply.mjs +159 -0
- package/stack/bootstrap/_lib.sh +73 -0
- package/stack/bootstrap/ngrok.sh +90 -0
- package/stack/catalog/ngrok.yml +63 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// A2 Phase 1 (D-java-analyzer): a dependency-free, balanced-token/masking analyzer that replaces
|
|
2
|
+
// the ad-hoc regex Java-source parsing previously duplicated across scanners/adapters/
|
|
3
|
+
// java-spring.mjs, contracts/emit.mjs, and handles/providers/java-spring/plan.mjs. See
|
|
4
|
+
// D-java-analyzer in DECISIONS.md for the exact known-broken shapes this fixes (test/fixtures/
|
|
5
|
+
// java-spring/.../annotationstyles/presentation/, pinned by P3/D-fixture-corpus as this item's
|
|
6
|
+
// own before/after baseline).
|
|
7
|
+
//
|
|
8
|
+
// LEADING UNDERSCORE IS LOAD-BEARING, NOT STYLISTIC: scanners/registry.mjs's candidateFiles()
|
|
9
|
+
// treats every non-`_`/`.`-prefixed .mjs file directly under scanners/adapters/ as a candidate
|
|
10
|
+
// scanner adapter to dynamically import() and validate against sbf.adapter/1 -- this file exports
|
|
11
|
+
// pure functions only, no `adapter`, and a non-underscored name would land in LOAD_ERRORS as a
|
|
12
|
+
// broken adapter (the registry's own comment already documents this exact convention).
|
|
13
|
+
import { lineNumberAt } from '../text-util.mjs';
|
|
14
|
+
|
|
15
|
+
const MASK_RE = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|"""[\s\S]*?"""|"(?:[^"\\]|\\.)*"/g;
|
|
16
|
+
|
|
17
|
+
// Same length, same line breaks (newlines preserved) as `text`, every OTHER character's index
|
|
18
|
+
// staying identical. A comment (block or line) is blanked to spaces ENTIRELY, markers included --
|
|
19
|
+
// it should never look structurally like anything. A string literal (regular or a Java text
|
|
20
|
+
// block) keeps its own opening/closing quote delimiters and blanks only the INTERIOR -- found
|
|
21
|
+
// live while wiring this up: blanking the quotes too broke `operationId\s*=\s*"` position-finding
|
|
22
|
+
// for every REAL annotation, not just phantom comment mentions, since the pattern needs a literal
|
|
23
|
+
// `"` right after `=` to know a quoted value follows at all. Used for every STRUCTURAL scan (where
|
|
24
|
+
// a class/annotation/method starts) so a comment or a string's CONTENT can never masquerade as
|
|
25
|
+
// code -- this is what fixes the phantom-operationId bug (a `//` comment mentioning
|
|
26
|
+
// `operationId = "..."` as prose) as a side effect, not a special case. VALUES (e.g. a real
|
|
27
|
+
// annotation's quoted path) are never read off the masked text -- every value-extraction step
|
|
28
|
+
// re-slices the ORIGINAL text at the offset the masked scan found, since masking never shifts
|
|
29
|
+
// offsets, only blanks content.
|
|
30
|
+
export function maskNonCode(text) {
|
|
31
|
+
return text.replace(MASK_RE, (m) => {
|
|
32
|
+
if (m.startsWith('"""')) return `"""${m.slice(3, -3).replace(/[^\n]/g, ' ')}"""`;
|
|
33
|
+
if (m.startsWith('"')) return `"${m.slice(1, -1).replace(/[^\n]/g, ' ')}"`;
|
|
34
|
+
return m.replace(/[^\n]/g, ' ');
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Generic balanced-delimiter scanner -- `text[openIndex]` must be `openChar`. Same algorithm
|
|
39
|
+
// scanners/adapters/python-fastapi.mjs's own local matchBalancedParens() already uses for `(`/`)`
|
|
40
|
+
// (a decorator's own kwargs routinely nest parens); parameterized here so one function also
|
|
41
|
+
// covers `<`/`>` (a generic return type's own nested generics, e.g. Map<String, List<Foo>>).
|
|
42
|
+
// python-fastapi.mjs's copy is left untouched -- same algorithm, but touching an already-working,
|
|
43
|
+
// unrelated adapter for a cosmetic dedup isn't a risk this item needs to take.
|
|
44
|
+
export function matchBalanced(text, openIndex, openChar, closeChar) {
|
|
45
|
+
let depth = 0;
|
|
46
|
+
for (let i = openIndex; i < text.length; i++) {
|
|
47
|
+
if (text[i] === openChar) depth++;
|
|
48
|
+
else if (text[i] === closeChar) {
|
|
49
|
+
depth--;
|
|
50
|
+
if (depth === 0) return i;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return -1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const CLASS_OR_RECORD_RE = /(?:public\s+)?(class|record)\s+(\w+)/;
|
|
57
|
+
|
|
58
|
+
// Adds `record` support and makes `public` optional (a package-private class is still a legal
|
|
59
|
+
// Spring bean) in the one place both extractController() and extractEntity() already duplicate
|
|
60
|
+
// this exact pattern. Operates on masked text -- callers pass maskNonCode(text).
|
|
61
|
+
export function findClassOrRecordDeclaration(maskedText) {
|
|
62
|
+
const m = maskedText.match(CLASS_OR_RECORD_RE);
|
|
63
|
+
if (!m) return null;
|
|
64
|
+
return { keyword: m[1], name: m[2], index: m.index };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const WHITESPACE_RE = /^\s*/;
|
|
68
|
+
// A2 Phase 2 (D-java-ast-helper): `[\w.]+`, not `\w+` -- found live while building the real
|
|
69
|
+
// JavaParser/Symbol-Solver AST cross-check. A fully-qualified annotation
|
|
70
|
+
// (`@jakarta.validation.constraints.NotNull`) is legal, ordinary Java; the old `\w+`-only pattern
|
|
71
|
+
// matched just `@jakarta`, so skipAnnotationsAndWhitespace() stopped mid-annotation and left
|
|
72
|
+
// `.validation.constraints.NotNull String description` as "the rest" -- which extractTypeAndName()
|
|
73
|
+
// (patch-strategy.mjs) then misparsed as `baseType=".validation.constraints.NotNull"`,
|
|
74
|
+
// `fieldName="String"`, silently corrupting an unrelated field's own name and type, not merely
|
|
75
|
+
// failing to recognize the annotation's meaning. This is a structural parse fix, not a semantic
|
|
76
|
+
// one -- a fully-qualified `@NotNull` still isn't recognized as meaning NotNull by
|
|
77
|
+
// `/@NotNull\b/`-style literal checks (patch-strategy.mjs's classifyParam, plan.mjs's authority
|
|
78
|
+
// search); that gap is exactly what `--ast`'s cross-check exists to surface, not paper over here.
|
|
79
|
+
const ANNOTATION_START_RE = /^@[\w.]+/;
|
|
80
|
+
const MODIFIER_RE = /^(?:public|protected)?\s*(?:static\s+)?/;
|
|
81
|
+
const IDENTIFIER_RE = /^[\w.]+/;
|
|
82
|
+
const METHOD_NAME_RE = /^(\w+)\s*\(/;
|
|
83
|
+
|
|
84
|
+
// From `index`, repeatedly skips whitespace (masked comments blank to spaces, so this skips them
|
|
85
|
+
// too, for free) and any COMPLETE annotation (`@Word` + an optional balanced `(...)` arg list),
|
|
86
|
+
// stopping at the first position that is neither. This one general mechanism is what makes
|
|
87
|
+
// same-line mappings, intervening annotations, and comments-in-between all "just work" instead of
|
|
88
|
+
// needing a special case per broken shape. Operates on masked text.
|
|
89
|
+
export function skipAnnotationsAndWhitespace(maskedText, index) {
|
|
90
|
+
let i = index;
|
|
91
|
+
for (;;) {
|
|
92
|
+
const ws = maskedText.slice(i).match(WHITESPACE_RE);
|
|
93
|
+
i += ws[0].length;
|
|
94
|
+
const ann = maskedText.slice(i).match(ANNOTATION_START_RE);
|
|
95
|
+
if (!ann) return i;
|
|
96
|
+
i += ann[0].length;
|
|
97
|
+
if (maskedText[i] === '(') {
|
|
98
|
+
const close = matchBalanced(maskedText, i, '(', ')');
|
|
99
|
+
if (close === -1) return i; // malformed -- stop here rather than loop forever
|
|
100
|
+
i = close + 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// From the position skipAnnotationsAndWhitespace() returns, matches an optional public|protected
|
|
106
|
+
// modifier, optional static, a return type (an identifier optionally followed by a balanced
|
|
107
|
+
// <...> generic and/or []), a method name, and the balanced parameter-list parens. Returns null
|
|
108
|
+
// if nothing method-shaped follows -- e.g. a class-level annotation stack that precedes `class`,
|
|
109
|
+
// not a method -- a legitimate, silent skip, not an error. `private` is deliberately excluded:
|
|
110
|
+
// Spring never routes a private method as an endpoint. Operates on masked text.
|
|
111
|
+
export function matchMethodSignatureAfter(maskedText, index) {
|
|
112
|
+
let i = index;
|
|
113
|
+
const mod = maskedText.slice(i).match(MODIFIER_RE);
|
|
114
|
+
i += mod[0].length;
|
|
115
|
+
|
|
116
|
+
const type = maskedText.slice(i).match(IDENTIFIER_RE);
|
|
117
|
+
if (!type) return null;
|
|
118
|
+
i += type[0].length;
|
|
119
|
+
|
|
120
|
+
if (maskedText[i] === '<') {
|
|
121
|
+
const close = matchBalanced(maskedText, i, '<', '>');
|
|
122
|
+
if (close === -1) return null;
|
|
123
|
+
i = close + 1;
|
|
124
|
+
}
|
|
125
|
+
while (maskedText.slice(i, i + 2) === '[]') i += 2;
|
|
126
|
+
|
|
127
|
+
const ws = maskedText.slice(i).match(WHITESPACE_RE);
|
|
128
|
+
i += ws[0].length;
|
|
129
|
+
|
|
130
|
+
const nameMatch = maskedText.slice(i).match(METHOD_NAME_RE);
|
|
131
|
+
if (!nameMatch) return null;
|
|
132
|
+
const methodName = nameMatch[1];
|
|
133
|
+
const parensOpen = i + nameMatch[0].length - 1; // index of the method's own '('
|
|
134
|
+
const parensClose = matchBalanced(maskedText, parensOpen, '(', ')');
|
|
135
|
+
if (parensClose === -1) return null;
|
|
136
|
+
|
|
137
|
+
return { methodName, paramsStart: parensOpen + 1, paramsEnd: parensClose, matchEnd: parensClose + 1 };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const MAPPING_VERBS = ['Get', 'Post', 'Put', 'Patch', 'Delete'];
|
|
141
|
+
const MAPPING_ANNOTATION_RE = new RegExp(`@(?:(${MAPPING_VERBS.join('|')})Mapping|RequestMapping)\\b`, 'g');
|
|
142
|
+
const REQUEST_MAPPING_RE = /@RequestMapping\b/g;
|
|
143
|
+
const CLASS_OR_RECORD_START_RE = /^(?:public\s+)?(?:class|record)\b/;
|
|
144
|
+
const REQUEST_MAPPING_METHOD_RE = /\bmethod\s*=\s*RequestMethod\.(\w+)\b/;
|
|
145
|
+
|
|
146
|
+
// True when `class`/`record` is the next real declaration after `index`, ONE OR MORE further
|
|
147
|
+
// annotations allowed in between (e.g. a real oracle shape: `@RequestMapping(...)
|
|
148
|
+
// @RequiredArgsConstructor public class Foo` -- found live, not anticipated, while verifying this
|
|
149
|
+
// item against the real Team-IZ-Backend repo: a rigid "class/record within the next N chars"
|
|
150
|
+
// lookahead missed exactly this, the same intervening-annotation problem
|
|
151
|
+
// skipAnnotationsAndWhitespace() already solves for methods, just not yet applied here too).
|
|
152
|
+
function isClassOrRecordAhead(masked, index) {
|
|
153
|
+
const after = skipAnnotationsAndWhitespace(masked, index);
|
|
154
|
+
return CLASS_OR_RECORD_START_RE.test(masked.slice(after, after + 50));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The class-level counterpart findMappingAnnotations() deliberately skips -- a @RequestMapping
|
|
158
|
+
// that (mod whitespace and any further annotations) precedes `class`/`record` is the controller's
|
|
159
|
+
// own base path, not a method. Returns the raw, ORIGINAL-text (unmasked) args substring, or null
|
|
160
|
+
// if this class has no class-level @RequestMapping at all. Shares the exact same lookahead helper
|
|
161
|
+
// findMappingAnnotations() excludes by, so the two functions can never disagree about which
|
|
162
|
+
// @RequestMapping occurrence is the class-level one.
|
|
163
|
+
export function findClassLevelMappingArgs(text) {
|
|
164
|
+
const masked = maskNonCode(text);
|
|
165
|
+
let m;
|
|
166
|
+
REQUEST_MAPPING_RE.lastIndex = 0;
|
|
167
|
+
while ((m = REQUEST_MAPPING_RE.exec(masked))) {
|
|
168
|
+
let i = m.index + m[0].length;
|
|
169
|
+
let argsStart = -1;
|
|
170
|
+
let argsEnd = -1;
|
|
171
|
+
if (masked[i] === '(') {
|
|
172
|
+
const close = matchBalanced(masked, i, '(', ')');
|
|
173
|
+
if (close === -1) continue;
|
|
174
|
+
argsStart = i + 1;
|
|
175
|
+
argsEnd = close;
|
|
176
|
+
i = close + 1;
|
|
177
|
+
}
|
|
178
|
+
if (isClassOrRecordAhead(masked, i)) {
|
|
179
|
+
return argsStart >= 0 ? text.slice(argsStart, argsEnd) : '';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The one shared orchestrator both scanners/adapters/java-spring.mjs's extractController() and
|
|
186
|
+
// handles/providers/java-spring/plan.mjs's method-boundary search need. Masks once, finds every
|
|
187
|
+
// METHOD-level `@(Get|Post|Put|Patch|Delete)Mapping`/`@RequestMapping` occurrence (a `@RequestMapping`
|
|
188
|
+
// immediately followed by `class`/`record` is class-level basePath, not a method -- skipped here,
|
|
189
|
+
// handled by the caller's own class-level logic), resolves each one's verb (a shorthand's own
|
|
190
|
+
// name, or `method = RequestMethod.X` parsed from `@RequestMapping`'s own args -- single-verb
|
|
191
|
+
// only, matching the catalog's own literal `@RequestMapping(method=…)` wording; a `method = {A,
|
|
192
|
+
// B}` array is left unresolved and skipped, a documented gap, not silently guessed at), and
|
|
193
|
+
// confirms a real method signature follows. Returns entries in document order: `{index, verb,
|
|
194
|
+
// argsText, methodName, methodLine}` -- `argsText` and `methodLine` are always computed from the
|
|
195
|
+
// ORIGINAL (unmasked) `text`, never the masked copy, since real annotation argument values (e.g.
|
|
196
|
+
// a path string) must never be read from blanked-out content.
|
|
197
|
+
export function findMappingAnnotations(text) {
|
|
198
|
+
const masked = maskNonCode(text);
|
|
199
|
+
const results = [];
|
|
200
|
+
let m;
|
|
201
|
+
MAPPING_ANNOTATION_RE.lastIndex = 0;
|
|
202
|
+
while ((m = MAPPING_ANNOTATION_RE.exec(masked))) {
|
|
203
|
+
const atIndex = m.index;
|
|
204
|
+
const verbShorthand = m[1];
|
|
205
|
+
const isRequestMapping = verbShorthand === undefined;
|
|
206
|
+
let i = atIndex + m[0].length;
|
|
207
|
+
|
|
208
|
+
let argsStart = -1;
|
|
209
|
+
let argsEnd = -1;
|
|
210
|
+
if (masked[i] === '(') {
|
|
211
|
+
const close = matchBalanced(masked, i, '(', ')');
|
|
212
|
+
if (close === -1) continue; // malformed -- skip, don't misattribute
|
|
213
|
+
argsStart = i + 1;
|
|
214
|
+
argsEnd = close;
|
|
215
|
+
i = close + 1;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (isRequestMapping && isClassOrRecordAhead(masked, i)) {
|
|
219
|
+
continue; // class-level @RequestMapping -- the caller's own basePath logic handles this
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let verb;
|
|
223
|
+
if (verbShorthand) {
|
|
224
|
+
verb = verbShorthand.toUpperCase();
|
|
225
|
+
} else {
|
|
226
|
+
const argsOriginal = argsStart >= 0 ? text.slice(argsStart, argsEnd) : '';
|
|
227
|
+
const methodMatch = argsOriginal.match(REQUEST_MAPPING_METHOD_RE);
|
|
228
|
+
if (!methodMatch) continue; // no single-verb method= found -- unresolved, documented gap
|
|
229
|
+
verb = methodMatch[1].toUpperCase();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const afterAnnotations = skipAnnotationsAndWhitespace(masked, i);
|
|
233
|
+
const sig = matchMethodSignatureAfter(masked, afterAnnotations);
|
|
234
|
+
if (!sig) continue; // nothing method-shaped follows -- legitimate skip, not an error
|
|
235
|
+
|
|
236
|
+
results.push({
|
|
237
|
+
index: atIndex,
|
|
238
|
+
verb,
|
|
239
|
+
argsText: argsStart >= 0 ? text.slice(argsStart, argsEnd) : '',
|
|
240
|
+
methodName: sig.methodName,
|
|
241
|
+
methodLine: lineNumberAt(text, atIndex),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return results;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// A2 Phase 1: return type tolerates a generic nested up to 2 levels (e.g.
|
|
248
|
+
// ResponseEntity<Map<String, Object>>) via a manually-unrolled pattern -- a pragmatic, bounded
|
|
249
|
+
// trade-off, not a general parser (this file's own matchBalanced() already handles arbitrary
|
|
250
|
+
// nesting where it's used above; a real Spring controller return type in this codebase never
|
|
251
|
+
// nests deeper than this, so a bounded pattern is enough for Phase 1's own stated scope).
|
|
252
|
+
const RETURN_TYPE_RE = '[\\w.]+(?:<[^<>]*(?:<[^<>]*>[^<>]*)*>)?(?:\\[\\])?';
|
|
253
|
+
|
|
254
|
+
// Finds `methodName`'s own parameter-list text -- used by contracts/emit.mjs's
|
|
255
|
+
// detectRequestBody(). Confirmed live (not assumed) against the previous non-greedy `([\s\S]*?)
|
|
256
|
+
// \)\s*\{` regex: a return type with a space inside a generic (`ResponseEntity<Map<String,
|
|
257
|
+
// Object>>`) failed to match AT ALL (the old regex's `\S+` return-type slot can't span
|
|
258
|
+
// whitespace), the exact same root cause scanners/adapters/java-spring.mjs's
|
|
259
|
+
// GenericWithSpaceController fixture already pins for extractController(). Returns the ORIGINAL
|
|
260
|
+
// (unmasked) parameter-list text, found via matchBalanced() from the method's own `(` --
|
|
261
|
+
// correctly bounded regardless of a default-value expression's own nested parens (e.g.
|
|
262
|
+
// `@RequestParam(defaultValue = "false") boolean force`) by construction, not by the old regex's
|
|
263
|
+
// accidental correctness. Returns null if no declaration of `methodName` is found.
|
|
264
|
+
export function findMethodParams(text, methodName) {
|
|
265
|
+
const masked = maskNonCode(text);
|
|
266
|
+
const declRe = new RegExp(`(?:public|protected)?\\s*(?:static\\s+)?${RETURN_TYPE_RE}\\s+${methodName}\\s*\\(`);
|
|
267
|
+
const m = masked.match(declRe);
|
|
268
|
+
if (!m) return null;
|
|
269
|
+
const parenOpen = m.index + m[0].length - 1; // index of the method's own '('
|
|
270
|
+
const parenClose = matchBalanced(masked, parenOpen, '(', ')');
|
|
271
|
+
if (parenClose === -1) return null;
|
|
272
|
+
return text.slice(parenOpen + 1, parenClose);
|
|
273
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Non-Java fallback: route-pattern grep across common frameworks. Explicitly lower confidence
|
|
2
|
+
// than the java-spring adapter (see scanners/index.mjs) -- this is a safety net, not a target.
|
|
3
|
+
// G3: reconnaissance only -- see D-generic-grep-reconnaissance in DECISIONS.md for why this
|
|
4
|
+
// stays deliberately shallow (no module inference, no confidence scoring without real corpus
|
|
5
|
+
// data to calibrate it) rather than growing into a second real adapter.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { lineNumberAt } from '../text-util.mjs';
|
|
10
|
+
|
|
11
|
+
// Verb capture group index: express/express-router/fastapi all capture the HTTP verb in m[1].
|
|
12
|
+
// flask's `@app.route(...)` has no verb group (it defaults to GET unless a `methods=[...]`
|
|
13
|
+
// kwarg is present, which isn't parsed here -- that's not "cheap" the way the other three are).
|
|
14
|
+
//
|
|
15
|
+
// express-router's `router\.(get|...)\(` and fastapi's `@router\.(get|...)\(` share the bare
|
|
16
|
+
// `router.get(` substring -- without the `(?<!@)` negative lookbehind below, FastAPI's
|
|
17
|
+
// `@router.get(...)` matched BOTH patterns, double-counting every FastAPI route once as
|
|
18
|
+
// "express-router" and once as "fastapi" (found while grouping routes by file for the scoring
|
|
19
|
+
// fix below -- it was silently inflating the flat per-route list before, just less visibly).
|
|
20
|
+
const ROUTE_PATTERNS = [
|
|
21
|
+
{ re: /app\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/gi, framework: 'express', hasVerb: true },
|
|
22
|
+
{ re: /(?<!@)router\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/gi, framework: 'express-router', hasVerb: true },
|
|
23
|
+
{ re: /@app\.route\(\s*["']([^"']+)["']/g, framework: 'flask', hasVerb: false },
|
|
24
|
+
{ re: /@router\.(get|post|put|patch|delete)\(\s*["']([^"']+)["']/gi, framework: 'fastapi', hasVerb: true },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// O6: rg --files order isn't guaranteed (no --sort -- ripgrep's own docs say the default is
|
|
28
|
+
// unordered/parallel) -- without sorting, two runs against an unchanged repo could produce
|
|
29
|
+
// controllers in a different order, causing spurious output diffs. See java-spring.mjs's
|
|
30
|
+
// listJavaFiles for the same fix.
|
|
31
|
+
function listCandidateFiles(repoRoot) {
|
|
32
|
+
try {
|
|
33
|
+
const out = execFileSync('rg', ['--files', '-g', '*.{js,ts,mjs,py}', repoRoot], { encoding: 'utf8' });
|
|
34
|
+
return out.split('\n').filter(Boolean).sort();
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Segment-aware longest common prefix -- '/users' and '/users/:id' share '/users', but '/us'
|
|
41
|
+
// (a naive character-wise prefix) is not a real route segment and would be a misleading
|
|
42
|
+
// basePath. A single route is trivially its own "common prefix". Falls back to '/' when there's
|
|
43
|
+
// no shared segment beyond the root.
|
|
44
|
+
function commonPathPrefix(paths) {
|
|
45
|
+
const segmentLists = paths.map((p) => p.split('/').filter(Boolean));
|
|
46
|
+
const shortest = Math.min(...segmentLists.map((s) => s.length));
|
|
47
|
+
const shared = [];
|
|
48
|
+
for (let i = 0; i < shortest; i++) {
|
|
49
|
+
const seg = segmentLists[0][i];
|
|
50
|
+
if (segmentLists.every((s) => s[i] === seg)) shared.push(seg);
|
|
51
|
+
else break;
|
|
52
|
+
}
|
|
53
|
+
return shared.length > 0 ? `/${shared.join('/')}` : '/';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function scanGenericGrep(repoRoot) {
|
|
57
|
+
const candidateFiles = listCandidateFiles(repoRoot);
|
|
58
|
+
// S2 (D-gate-precision, continued): repo-relative, matching every other manifest-shaped gate
|
|
59
|
+
// input in this codebase. Included in BOTH return paths below -- a file with zero route
|
|
60
|
+
// matches was still genuinely read, and the `scan` gate's staleness token needs to notice a
|
|
61
|
+
// change to it (e.g. a route pattern added later) just as much as a file that already matched.
|
|
62
|
+
const filesRead = candidateFiles.map((f) => path.relative(repoRoot, f));
|
|
63
|
+
const routes = [];
|
|
64
|
+
for (const file of candidateFiles) {
|
|
65
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
66
|
+
for (const { re, framework, hasVerb } of ROUTE_PATTERNS) {
|
|
67
|
+
for (const m of text.matchAll(re)) {
|
|
68
|
+
const verb = hasVerb ? m[1].toUpperCase() : '?';
|
|
69
|
+
const routePath = hasVerb ? m[2] : m[1];
|
|
70
|
+
routes.push({ framework, verb, path: routePath, file, line: lineNumberAt(text, m.index) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (routes.length === 0) return { modules: [], filesRead };
|
|
75
|
+
|
|
76
|
+
// Group by source file -- the natural code-module boundary for this adapter, the same role a
|
|
77
|
+
// controller class plays for java-spring. Before this, every matched route became its own
|
|
78
|
+
// separate fake "controller", so scoreModule()'s className match (+6 per controller) was
|
|
79
|
+
// counted once PER ROUTE instead of once per file -- a repo with 50 express routes would
|
|
80
|
+
// score 300 on the term "express" alone, regardless of actual module relevance.
|
|
81
|
+
const byFile = new Map();
|
|
82
|
+
for (const r of routes) {
|
|
83
|
+
if (!byFile.has(r.file)) byFile.set(r.file, []);
|
|
84
|
+
byFile.get(r.file).push(r);
|
|
85
|
+
}
|
|
86
|
+
const controllers = [...byFile.entries()].map(([file, fileRoutes]) => ({
|
|
87
|
+
className: fileRoutes[0].framework,
|
|
88
|
+
basePath: commonPathPrefix(fileRoutes.map((r) => r.path)),
|
|
89
|
+
operationIds: [],
|
|
90
|
+
endpoints: fileRoutes.map((r) => ({ verb: r.verb, path: r.path, operationId: null, line: r.line })),
|
|
91
|
+
file,
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
return { modules: [{ module: '_generic', controllers, entities: [], enums: [], dtos: [] }], filesRead };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// G1: adapter descriptor consumed by scanners/registry.mjs -- see D-adapter-registry in
|
|
98
|
+
// DECISIONS.md. `id` must equal this file's stem ("generic-grep"). specificity 0: unconditional
|
|
99
|
+
// last-resort fallback -- detect() always returns true, even when it finds zero routes, matching
|
|
100
|
+
// the pre-G1 semantics where "java-spring failed to detect" always meant "generic-grep is used",
|
|
101
|
+
// regardless of what generic-grep itself finds. See D-generic-grep-reconnaissance for why every
|
|
102
|
+
// capability below is honestly false rather than a best-effort partial implementation.
|
|
103
|
+
export const adapter = {
|
|
104
|
+
contract: 'sbf.adapter/1',
|
|
105
|
+
id: 'generic-grep',
|
|
106
|
+
title: 'Generic route-pattern grep (fallback)',
|
|
107
|
+
specificity: 0,
|
|
108
|
+
confidence: 'low',
|
|
109
|
+
capabilities: {
|
|
110
|
+
'api.operations': false,
|
|
111
|
+
'api.request-shape': false,
|
|
112
|
+
'resource.fetch': false,
|
|
113
|
+
'codegen.handles': false,
|
|
114
|
+
},
|
|
115
|
+
detect() {
|
|
116
|
+
return true;
|
|
117
|
+
},
|
|
118
|
+
scan(repoRoot, _detection) {
|
|
119
|
+
return scanGenericGrep(repoRoot);
|
|
120
|
+
},
|
|
121
|
+
// S2 (D-gate-precision, continued): same listCandidateFiles() call scan() itself makes.
|
|
122
|
+
listReadSet(repoRoot) {
|
|
123
|
+
return listCandidateFiles(repoRoot).map((f) => path.relative(repoRoot, f));
|
|
124
|
+
},
|
|
125
|
+
diagnostics() {
|
|
126
|
+
return [{ level: 'info', code: 'always-detects', message: 'this is the unconditional last-resort fallback adapter (specificity 0) -- it always "detects"' }];
|
|
127
|
+
},
|
|
128
|
+
};
|