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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/bin/bskel.mjs +2384 -0
  4. package/contracts/completeness.mjs +176 -0
  5. package/contracts/emit.mjs +287 -0
  6. package/contracts/export.mjs +325 -0
  7. package/contracts/openapi.mjs +869 -0
  8. package/contracts/validate.mjs +147 -0
  9. package/handles/_engine.mjs +281 -0
  10. package/handles/codec.mjs +119 -0
  11. package/handles/conformance.mjs +74 -0
  12. package/handles/providers/java-spring/ast-bridge.mjs +59 -0
  13. package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
  14. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
  15. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
  16. package/handles/providers/java-spring/ast-helper/gradlew +248 -0
  17. package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
  18. package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
  19. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
  20. package/handles/providers/java-spring/emit.mjs +232 -0
  21. package/handles/providers/java-spring/patch-strategy.mjs +229 -0
  22. package/handles/providers/java-spring/plan.mjs +377 -0
  23. package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
  24. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
  25. package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
  26. package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
  27. package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
  28. package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
  29. package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
  30. package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
  31. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
  32. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
  33. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
  34. package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
  35. package/handles/providers/java-spring.mjs +21 -0
  36. package/handles/providers/python-fastapi/emit.mjs +171 -0
  37. package/handles/providers/python-fastapi/plan.mjs +186 -0
  38. package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
  39. package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
  40. package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
  41. package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
  42. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
  43. package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
  44. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
  45. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
  46. package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
  47. package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
  48. package/handles/providers/python-fastapi.mjs +22 -0
  49. package/handles/providers/typescript-express/emit.mjs +128 -0
  50. package/handles/providers/typescript-express/plan.mjs +234 -0
  51. package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
  52. package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
  53. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
  54. package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
  55. package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
  56. package/handles/providers/typescript-express.mjs +20 -0
  57. package/handles/registry.mjs +90 -0
  58. package/lib/cli.mjs +430 -0
  59. package/lib/doctor.mjs +200 -0
  60. package/lib/exit-codes.mjs +67 -0
  61. package/lib/featureid.mjs +55 -0
  62. package/lib/featurelifecycle.mjs +205 -0
  63. package/lib/fsutil.mjs +50 -0
  64. package/lib/gate-definitions.mjs +293 -0
  65. package/lib/gates.mjs +263 -0
  66. package/lib/handles-manifest.mjs +92 -0
  67. package/lib/lock.mjs +68 -0
  68. package/lib/patch-approvals.mjs +56 -0
  69. package/lib/paths.mjs +21 -0
  70. package/lib/repo.mjs +44 -0
  71. package/lib/schema-validate.mjs +56 -0
  72. package/lib/state.mjs +124 -0
  73. package/lib/template.mjs +35 -0
  74. package/lib/verify.mjs +206 -0
  75. package/lib/workflow.mjs +142 -0
  76. package/new/fastapi.mjs +165 -0
  77. package/new/index.mjs +62 -0
  78. package/new/params.mjs +233 -0
  79. package/new/spring.mjs +198 -0
  80. package/new/templates/fastapi/README.md +26 -0
  81. package/new/templates/fastapi/app/__init__.py +0 -0
  82. package/new/templates/fastapi/app/main.py +8 -0
  83. package/new/templates/fastapi/gitignore +6 -0
  84. package/new/templates/fastapi/pyproject.toml +14 -0
  85. package/package.json +50 -0
  86. package/scanners/adapters/_express-shared.mjs +238 -0
  87. package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
  88. package/scanners/adapters/generic-grep.mjs +128 -0
  89. package/scanners/adapters/java-spring.mjs +301 -0
  90. package/scanners/adapters/javascript-express.mjs +422 -0
  91. package/scanners/adapters/python-fastapi.mjs +348 -0
  92. package/scanners/adapters/typescript-express.mjs +299 -0
  93. package/scanners/capabilities.mjs +90 -0
  94. package/scanners/conformance.mjs +59 -0
  95. package/scanners/db/introspect.mjs +109 -0
  96. package/scanners/db/migrations.mjs +126 -0
  97. package/scanners/index.mjs +281 -0
  98. package/scanners/registry.mjs +130 -0
  99. package/scanners/render.mjs +136 -0
  100. package/scanners/text-util.mjs +8 -0
  101. package/schemas/adapter.schema.json +23 -0
  102. package/schemas/agent-envelope.schema.json +21 -0
  103. package/schemas/contract-resolution.schema.json +28 -0
  104. package/schemas/feature-contract.schema.json +78 -0
  105. package/schemas/feature-index.schema.json +25 -0
  106. package/schemas/feature.schema.json +17 -0
  107. package/schemas/gate-event.schema.json +19 -0
  108. package/schemas/handles-plan.schema.json +31 -0
  109. package/schemas/handles-provider.schema.json +26 -0
  110. package/schemas/patch-approvals.schema.json +28 -0
  111. package/schemas/scan-report.schema.json +102 -0
  112. package/schemas/stack-choice.schema.json +89 -0
  113. package/schemas/stack-record.schema.json +20 -0
  114. package/schemas/state.schema.json +43 -0
  115. package/scripts/preflight-base-ref.sh +226 -0
  116. package/stack/apply.mjs +159 -0
  117. package/stack/bootstrap/_lib.sh +73 -0
  118. package/stack/bootstrap/ngrok.sh +90 -0
  119. 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
+ };