backend-skeleton 1.0.0 → 1.1.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/README.md +66 -4
- package/bin/bskel.mjs +125 -18
- package/contracts/export.mjs +39 -4
- package/contracts/openapi.mjs +292 -27
- package/contracts/validate.mjs +23 -4
- package/handles/_engine.mjs +75 -32
- package/handles/capability-codec.mjs +94 -0
- package/handles/codec.mjs +13 -3
- package/handles/providers/java-spring/emit.mjs +78 -33
- package/handles/providers/java-spring/observe.mjs +4 -3
- package/handles/providers/java-spring/plan.mjs +51 -7
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +13 -7
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +24 -3
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
- package/handles/providers/java-spring.mjs +8 -0
- package/handles/providers/python-fastapi/emit.mjs +21 -26
- package/handles/providers/python-fastapi/observe.mjs +6 -5
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +32 -4
- package/handles/providers/python-fastapi.mjs +3 -3
- package/handles/providers/typescript-express/emit.mjs +135 -46
- package/handles/providers/typescript-express/observe.mjs +7 -6
- package/handles/providers/typescript-express/plan.mjs +8 -1
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
- package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
- package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
- package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
- package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
- package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
- package/handles/providers/typescript-express.mjs +7 -4
- package/lib/cli.mjs +11 -2
- package/lib/exit-codes.mjs +21 -0
- package/lib/verify.mjs +23 -6
- package/package.json +5 -2
- package/scanners/adapters/_java-spring-analyzer.mjs +25 -2
- package/scanners/adapters/java-spring.mjs +108 -10
- package/scanners/adapters/javascript-express.mjs +46 -13
- package/scanners/adapters/python-fastapi.mjs +63 -18
- package/scanners/adapters/typescript-express.mjs +33 -7
- package/schemas/feature-contract.schema.json +3 -3
- package/schemas/handles-plan.schema.json +2 -0
- package/schemas/oracle-manifest.schema.json +58 -0
- package/schemas/stack-record.schema.json +6 -1
- package/stack/apply.mjs +47 -6
|
@@ -226,10 +226,40 @@ function resolveEsmImport(fromFile, specifier, suffixes) {
|
|
|
226
226
|
return null;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
229
|
+
// D-javascript-express-adapter (Update): found by the same shadow-validation-style audit that
|
|
230
|
+
// closed D-module-attribution-base-package's own EXIT item for this adapter -- cross-file mount
|
|
231
|
+
// resolution only ever recognized `export default router;`, a real but narrower limitation than
|
|
232
|
+
// java-spring's own moduleOf() bug (endpoints still get FOUND either way, only their prefix goes
|
|
233
|
+
// unresolved). A router handed off via a bare named export (`export { router };`) or an
|
|
234
|
+
// export-prefixed declaration (`export const router = Router();`, ordinary and common) previously
|
|
235
|
+
// had no path to being recognized as this file's "the" exported mountable at all.
|
|
236
|
+
//
|
|
237
|
+
// Three real ways a module hands a locally-declared mountable to whoever imports it -- `export
|
|
238
|
+
// { router as r }` aliasing is deliberately NOT resolved, same restraint as this file's own
|
|
239
|
+
// `Router as R` import-aliasing decision (D-javascript-express-adapter COST): a documented, narrow
|
|
240
|
+
// limitation, not a silent guess at which local name an alias refers to.
|
|
241
|
+
function exportedMountableName(text, mountables) {
|
|
242
|
+
const defaultMatch = text.match(/export\s+default\s+([\w$]+)\s*;?/);
|
|
243
|
+
if (defaultMatch && mountables.has(defaultMatch[1])) return defaultMatch[1];
|
|
244
|
+
const namedMatch = text.match(/export\s*\{\s*([\w$]+)\s*\}/);
|
|
245
|
+
if (namedMatch && mountables.has(namedMatch[1])) return namedMatch[1];
|
|
246
|
+
const exportedDeclMatch = text.match(/\bexport\s+(?:const|let|var)\s+([\w$]+)\s*=/);
|
|
247
|
+
if (exportedDeclMatch && mountables.has(exportedDeclMatch[1])) return exportedDeclMatch[1];
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// `import target from '...'` (default) OR `import { target } from '...'` (named, unaliased) --
|
|
252
|
+
// two real ways an imported mountable's LOCAL name reaches this file. `import { target as alias }`
|
|
253
|
+
// is deliberately not resolved, same restraint as exportedMountableName's own aliasing decision
|
|
254
|
+
// above -- a bare, unaliased single-name clause only, matching this file's existing default-import
|
|
255
|
+
// regex's own narrow scope (never a general multi-specifier import-clause parser).
|
|
256
|
+
function importSourceFor(text, target) {
|
|
257
|
+
const defaultImportRe = new RegExp(`import\\s+${target}\\s*(?:,\\s*\\{[^}]*\\})?\\s*from\\s*["']([^"']+)["']`);
|
|
258
|
+
const defaultMatch = text.match(defaultImportRe);
|
|
259
|
+
if (defaultMatch) return defaultMatch[1];
|
|
260
|
+
const namedImportRe = new RegExp(`import\\s*\\{\\s*${target}\\s*\\}\\s*from\\s*["']([^"']+)["']`);
|
|
261
|
+
const namedMatch = text.match(namedImportRe);
|
|
262
|
+
return namedMatch ? namedMatch[1] : null;
|
|
233
263
|
}
|
|
234
264
|
|
|
235
265
|
// Builds the mount graph over (file, variable) nodes. Two edge kinds, both from the same
|
|
@@ -261,13 +291,12 @@ function buildMountEdges(files, fileInfo, suffixes) {
|
|
|
261
291
|
edges.push({ from: nodeKey(file, fromVar), to: nodeKey(file, target), prefix: pathMatch[1] });
|
|
262
292
|
continue;
|
|
263
293
|
}
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const toFile = resolveEsmImport(file, importMatch[1], suffixes);
|
|
294
|
+
const importSource = importSourceFor(info.text, target);
|
|
295
|
+
if (!importSource) continue;
|
|
296
|
+
const toFile = resolveEsmImport(file, importSource, suffixes);
|
|
268
297
|
if (!toFile || !fileInfo.has(toFile)) continue;
|
|
269
298
|
const toInfo = fileInfo.get(toFile);
|
|
270
|
-
const toVar =
|
|
299
|
+
const toVar = exportedMountableName(toInfo.text, toInfo.mountables);
|
|
271
300
|
if (!toVar) continue;
|
|
272
301
|
edges.push({ from: nodeKey(file, fromVar), to: nodeKey(toFile, toVar), prefix: pathMatch[1] });
|
|
273
302
|
}
|
|
@@ -391,10 +420,14 @@ export const adapter = {
|
|
|
391
420
|
// walk), not how many capabilities it can offer. generic-grep is `low` because it has no module
|
|
392
421
|
// inference and no prefix resolution at all -- this adapter has both.
|
|
393
422
|
confidence: 'high',
|
|
394
|
-
// D-
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
|
|
423
|
+
// D-oracle-corpus-pinning: promoted from synthetic-only -- 3 real, pinned community repos
|
|
424
|
+
// now exist in test/fixtures/oracle-manifest.json (JeanCaicedo/employees-api-mysql,
|
|
425
|
+
// Serkanbyx/chat-app-backend, nekesam/helloworld), found via a genuine search effort that
|
|
426
|
+
// contradicted this adapter's own prior 'may be structurally unpromotable' hedge. The
|
|
427
|
+
// committed synthetic fixture still carries the exact-count regression weight; the real
|
|
428
|
+
// corpus is diagnostic/diversity coverage on top, same role every other adapter's real
|
|
429
|
+
// oracle plays.
|
|
430
|
+
verificationBasis: 'community-sample',
|
|
398
431
|
capabilities: {
|
|
399
432
|
// false: plain Express has no operationId concept at all. --openapi-file is the honest path
|
|
400
433
|
// forward for an app that has one; see CAPABILITY_SATISFIERS in scanners/capabilities.mjs.
|
|
@@ -23,7 +23,8 @@ const EXCLUDE_GLOBS = ['!**/.venv/**', '!**/site-packages/**', '!**/node_modules
|
|
|
23
23
|
// `fastapi==0.100.0`.
|
|
24
24
|
const FASTAPI_DEP_RE = /(?:^|[\s"'[])fastapi(?:\[[^\]]*\])?(?:[\s"',\]=<>~!;]|$)/mi;
|
|
25
25
|
|
|
26
|
-
const VERB_DECORATOR_RE =
|
|
26
|
+
const VERB_DECORATOR_RE = /@(\w+)\.(get|post|put|patch|delete)\s*\(/gi;
|
|
27
|
+
const ROUTER_DECL_RE = /(\w+)\s*=\s*APIRouter\s*\(/g;
|
|
27
28
|
const CLASS_RE = /^class\s+(\w+)\s*\(([^)]*)\)\s*:/gm;
|
|
28
29
|
const INCLUDE_ROUTER_RE = /include_router\s*\(/g;
|
|
29
30
|
|
|
@@ -104,24 +105,42 @@ function capitalize(s) {
|
|
|
104
105
|
// resolved against a global prefix applied elsewhere (e.g. `include_router(prefix=...)`) -- that
|
|
105
106
|
// asymmetry is exactly what `pathPrefixSignals`/`unknowns` exists to flag, same role java-spring's
|
|
106
107
|
// detectGlobalPathPrefixSignals plays for `configurePathMatch`/`context-path`.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
108
|
+
//
|
|
109
|
+
// D-fastapi-multi-router-per-file: a real dogfooding find against `polarsource/polar` (a 400+
|
|
110
|
+
// route production FastAPI monorepo) -- 3 real files (`checkout/endpoints.py`,
|
|
111
|
+
// `member/endpoints.py`, `auth/oauth2/router.py`) declare MORE THAN ONE `<var> = APIRouter(...)`
|
|
112
|
+
// in the same file (e.g. member.py's `router = APIRouter(prefix="/members")` plus a second,
|
|
113
|
+
// distinct `customer_members_router = APIRouter(prefix="/customers")` for a nested customer-scoped
|
|
114
|
+
// resource). The single-basePath-per-file design used to always read only the FIRST `APIRouter(`
|
|
115
|
+
// occurrence and apply it to every decorator in the file regardless of which router variable
|
|
116
|
+
// actually decorates it -- silently discarding the second router's own real prefix. Returns
|
|
117
|
+
// Map<varName, prefix> for every declared router in the file so each decorator can look up its
|
|
118
|
+
// OWN router's prefix instead.
|
|
119
|
+
function extractRouterPrefixes(text) {
|
|
120
|
+
const prefixes = new Map();
|
|
121
|
+
for (const m of text.matchAll(ROUTER_DECL_RE)) {
|
|
122
|
+
const openIdx = m.index + m[0].length - 1;
|
|
123
|
+
const closeIdx = matchBalancedParens(text, openIdx);
|
|
124
|
+
if (closeIdx === -1) continue;
|
|
125
|
+
const prefixMatch = text.slice(openIdx + 1, closeIdx).match(/prefix\s*=\s*["']([^"']*)["']/);
|
|
126
|
+
prefixes.set(m[1], prefixMatch ? prefixMatch[1] : '');
|
|
127
|
+
}
|
|
128
|
+
return prefixes;
|
|
115
129
|
}
|
|
116
130
|
|
|
117
131
|
// `operationId` is always null -- see the adapter's own `api.operations: false` and
|
|
118
132
|
// D-fastapi-adapter in DECISIONS.md: FastAPI generates operation ids at request-handling time
|
|
119
133
|
// (per-project, sometimes via a custom `generate_unique_id_function`), never pinned in source the
|
|
120
134
|
// way `@Operation(operationId=...)` is for Java, so there is nothing honest to statically correlate.
|
|
135
|
+
//
|
|
136
|
+
// D-fastapi-multi-router-per-file: `routerVar` (the exact identifier before `.get`/`.post`/...) is
|
|
137
|
+
// now captured per endpoint so the caller can resolve basePath per-router-variable rather than
|
|
138
|
+
// once for the whole file -- see extractRouterPrefixes() above.
|
|
121
139
|
function extractEndpoints(text) {
|
|
122
140
|
const endpoints = [];
|
|
123
141
|
for (const m of text.matchAll(VERB_DECORATOR_RE)) {
|
|
124
|
-
const
|
|
142
|
+
const routerVar = m[1];
|
|
143
|
+
const verb = m[2].toUpperCase();
|
|
125
144
|
const openIdx = m.index + m[0].length - 1;
|
|
126
145
|
const closeIdx = matchBalancedParens(text, openIdx);
|
|
127
146
|
if (closeIdx === -1) continue;
|
|
@@ -134,11 +153,19 @@ function extractEndpoints(text) {
|
|
|
134
153
|
const funcMatch = afterDecoratorRe.exec(text);
|
|
135
154
|
if (!funcMatch) continue;
|
|
136
155
|
|
|
137
|
-
endpoints.push({ verb, path: pathMatch[1], operationId: null, method: funcMatch[1], line: lineNumberAt(text, m.index) });
|
|
156
|
+
endpoints.push({ verb, path: pathMatch[1], operationId: null, method: funcMatch[1], routerVar, line: lineNumberAt(text, m.index) });
|
|
138
157
|
}
|
|
139
158
|
return endpoints;
|
|
140
159
|
}
|
|
141
160
|
|
|
161
|
+
// snake_case/mixed identifier -> PascalCase, e.g. "customer_members_router" -> "CustomerMembersRouter",
|
|
162
|
+
// "inner_router" -> "InnerRouter". Only used for a NON-default router variable name (see
|
|
163
|
+
// scanPythonFastApi below) -- the common single-router-per-file case keeps its existing
|
|
164
|
+
// `${capitalize(moduleName)}Router` className exactly as before, byte-for-byte.
|
|
165
|
+
function pascalCase(identifier) {
|
|
166
|
+
return identifier.split('_').filter(Boolean).map(capitalize).join('');
|
|
167
|
+
}
|
|
168
|
+
|
|
142
169
|
// SQLModel `class X(<bases>, table=True):` -- table name is the lowercased class name (SQLModel's
|
|
143
170
|
// own default when no explicit `__tablename__` is declared; cross-checked against the real
|
|
144
171
|
// oracle's own Alembic migration, `op.create_table("user", ...)`/`op.create_table("item", ...)`,
|
|
@@ -249,9 +276,10 @@ function extractIncludeRouterPrefixSignals(repoRoot, files) {
|
|
|
249
276
|
return signals;
|
|
250
277
|
}
|
|
251
278
|
|
|
252
|
-
// D-fastapi-adapter: paths are router-local (see
|
|
253
|
-
// at runtime, never pinned in source -- see extractEndpoints. --openapi-file +
|
|
254
|
-
// trustworthy path (contracts/openapi.mjs's existing, adapter-agnostic
|
|
279
|
+
// D-fastapi-adapter: paths are router-local (see extractRouterPrefixes); FastAPI generates
|
|
280
|
+
// operation ids at runtime, never pinned in source -- see extractEndpoints. --openapi-file +
|
|
281
|
+
// --path-prefix is the trustworthy path (contracts/openapi.mjs's existing, adapter-agnostic
|
|
282
|
+
// reconciliation).
|
|
255
283
|
const API_SURFACE_SOURCE = 'router-local paths only (this scan does not resolve a global prefix applied via ' +
|
|
256
284
|
'include_router(prefix=...) beyond a simple literal/single-variable lookup -- see unknowns below if one ' +
|
|
257
285
|
'was found) -- FastAPI generates operation ids at request-handling time (per-project, sometimes via a ' +
|
|
@@ -277,10 +305,27 @@ export function scanPythonFastApi(repoRoot, projectRoot) {
|
|
|
277
305
|
// login.py, which declares `APIRouter(tags=["login"])` with no prefix at all, so a
|
|
278
306
|
// prefix-derived name fails on a real file while the filename stem works for every one.
|
|
279
307
|
const moduleName = path.basename(file, '.py');
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
308
|
+
const routerPrefixes = extractRouterPrefixes(text);
|
|
309
|
+
const rawEndpoints = extractEndpoints(text);
|
|
310
|
+
|
|
311
|
+
// D-fastapi-multi-router-per-file: group by the router variable each decorator actually
|
|
312
|
+
// belongs to (not the file as a whole) -- a decorator on a router variable this file never
|
|
313
|
+
// itself declares (e.g. imported from elsewhere, or a same-file `include_router()` alias)
|
|
314
|
+
// falls back to '' rather than guessing, same as the pre-fix single-router behavior.
|
|
315
|
+
const byRouterVar = new Map();
|
|
316
|
+
for (const ep of rawEndpoints) {
|
|
317
|
+
if (!byRouterVar.has(ep.routerVar)) byRouterVar.set(ep.routerVar, []);
|
|
318
|
+
byRouterVar.get(ep.routerVar).push(ep);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
for (const [routerVar, eps] of byRouterVar) {
|
|
322
|
+
const basePath = routerPrefixes.get(routerVar) ?? '';
|
|
323
|
+
const endpoints = eps.map((ep) => ({ verb: ep.verb, path: joinPath(basePath, ep.path), operationId: ep.operationId, method: ep.method, line: ep.line }));
|
|
324
|
+
// the common case (single router per file, conventionally named "router") keeps the
|
|
325
|
+
// existing className exactly as before -- only a second/other-named router variable
|
|
326
|
+
// gets a distinguishing className derived from its own identifier.
|
|
327
|
+
const className = routerVar === 'router' ? `${capitalize(moduleName)}Router` : `${pascalCase(routerVar)}`;
|
|
328
|
+
moduleEntry(moduleName).controllers.push({ className, basePath, operationIds: [], endpoints, file });
|
|
284
329
|
}
|
|
285
330
|
}
|
|
286
331
|
|
|
@@ -41,6 +41,17 @@ const ENTITY_CLASS_RE = /@Entity\s*\(\s*(?:["'`]([^"'`]*)["'`])?\s*\)\s*\n?\s*ex
|
|
|
41
41
|
// exported symbol -- same file-level granularity java's own DTO tracking already settled for.
|
|
42
42
|
const DTO_DIR_SEGMENT = `${path.sep}dto${path.sep}`;
|
|
43
43
|
|
|
44
|
+
// D-module-attribution-base-package (Update): found by the same shadow-validation pass that fixed
|
|
45
|
+
// java-spring's own moduleOf() -- a real project not using a `dto/` folder at all (flat `CreateUserDto.ts` files, or
|
|
46
|
+
// NestJS's own common `create-user.dto.ts` naming) had every DTO silently invisible, the same class
|
|
47
|
+
// of single-convention overfit, just on a narrower surface (DTO tracking only, not module/entity/
|
|
48
|
+
// controller extraction). This does NOT reopen the CONTENT-detection problem the comment above
|
|
49
|
+
// explicitly rejected (interface/type/class-validator/Zod/undecorated class all have different
|
|
50
|
+
// shapes) -- it's an independent, NAME-only signal: the file's own basename ends in "dto"
|
|
51
|
+
// (case-insensitive), the same near-definitional marker this file's own entity-matching step below
|
|
52
|
+
// already leans on for MATCHING. Catches both `CreateUserDto.ts` and `create-user.dto.ts`.
|
|
53
|
+
const DTO_NAME_SUFFIX_RE = /dto$/i;
|
|
54
|
+
|
|
44
55
|
// Two independent signals required, mirroring java-spring's "build file AND src layout" /
|
|
45
56
|
// python-fastapi's "dependency declared AND source-confirmed" combined bar: (a) package.json
|
|
46
57
|
// declares express, (b) at least one .ts file actually imports Router from 'express' and calls
|
|
@@ -78,6 +89,20 @@ function listTypeScriptFiles(projectRoot) {
|
|
|
78
89
|
// of `router.use('/literal', subRouter)` mount edges from a graph root down to the leaf file. This
|
|
79
90
|
// extracts just the LOCAL endpoints (verb/path/handler/line) with an EMPTY prefix -- the mount-tree
|
|
80
91
|
// walk in scanTypeScriptExpress() below joins the real prefix chain afterward.
|
|
92
|
+
//
|
|
93
|
+
// D-typescript-express-inline-handlers: an inline function expression (`router.get('/x', async
|
|
94
|
+
// (req, res) => {...})`) is a DIFFERENT shape from a bare-identifier handler reference
|
|
95
|
+
// (`router.get('/x', show)`) -- confirmed live, dogfooding against a real, popular repo
|
|
96
|
+
// (gothinkster/node-express-realworld-example-app, 3,796 real GitHub stars): ALL 19 of its real
|
|
97
|
+
// route registrations use this inline form, and the bare-identifier-only gate this comment used to
|
|
98
|
+
// describe found ZERO of them (verdict: greenfield on a repo with a real, complete REST API).
|
|
99
|
+
// `method: null` (not a synthesized/guessed name) marks this case -- there is no export to
|
|
100
|
+
// correlate an inline handler to, and `handles/providers/typescript-express/plan.mjs`'s own
|
|
101
|
+
// `resolveHandlerFile()` already has a real, tested `null`-propagates-to-"resolver not generated"
|
|
102
|
+
// path for exactly this "handler correlates to nothing" case (see its own Update note in
|
|
103
|
+
// DECISIONS.md) -- this is NOT a new failure mode, just a new, real way to reach the existing one.
|
|
104
|
+
const INLINE_HANDLER_RE = /^(?:async\s+)?(?:\([^)]*\)|[$\w]+)\s*(?::[^=]*)?=>|^(?:async\s+)?function\b/;
|
|
105
|
+
|
|
81
106
|
function extractEndpoints(text) {
|
|
82
107
|
const endpoints = [];
|
|
83
108
|
for (const m of text.matchAll(VERB_CALL_RE)) {
|
|
@@ -91,13 +116,14 @@ function extractEndpoints(text) {
|
|
|
91
116
|
|
|
92
117
|
const args = splitTopLevelArgs(argsText);
|
|
93
118
|
const lastArg = args[args.length - 1]?.trim();
|
|
94
|
-
// A bare identifier only -- an inline arrow-function handler has no name to correlate to a
|
|
95
|
-
// controller file, so it's skipped rather than guessed at (same discipline as FastAPI's own
|
|
96
|
-
// "no path literal -> skip").
|
|
97
119
|
const handlerMatch = lastArg?.match(/^(\w+)$/);
|
|
98
|
-
|
|
120
|
+
const isInlineHandler = !handlerMatch && lastArg && INLINE_HANDLER_RE.test(lastArg);
|
|
121
|
+
// Neither a bare identifier nor a recognizable inline function expression (e.g. a member
|
|
122
|
+
// expression like `controller.show`, or something built dynamically) -- skip rather than
|
|
123
|
+
// guess, same discipline as the missing-path-literal case just above.
|
|
124
|
+
if (!handlerMatch && !isInlineHandler) continue;
|
|
99
125
|
|
|
100
|
-
endpoints.push({ verb, path: pathMatch[1], operationId: null, method: handlerMatch[1], line: lineNumberAt(text, m.index) });
|
|
126
|
+
endpoints.push({ verb, path: pathMatch[1], operationId: null, method: handlerMatch ? handlerMatch[1] : null, line: lineNumberAt(text, m.index) });
|
|
101
127
|
}
|
|
102
128
|
return endpoints;
|
|
103
129
|
}
|
|
@@ -251,8 +277,8 @@ export function scanTypeScriptExpress(repoRoot, projectRoot) {
|
|
|
251
277
|
moduleEntry(moduleName).controllers.push({ className, basePath: prefix, operationIds: [], endpoints, file });
|
|
252
278
|
}
|
|
253
279
|
}
|
|
254
|
-
if (file.includes(DTO_DIR_SEGMENT)) {
|
|
255
|
-
allDtos.push({ className: path.basename(file, '.ts'), file }); // no `line` -- path-based, no content parsed
|
|
280
|
+
if (file.includes(DTO_DIR_SEGMENT) || DTO_NAME_SUFFIX_RE.test(path.basename(file, '.ts'))) {
|
|
281
|
+
allDtos.push({ className: path.basename(file, '.ts'), file }); // no `line` -- path/name-based, no content parsed
|
|
256
282
|
}
|
|
257
283
|
allEntities.push(...extractTableEntities(text, file));
|
|
258
284
|
}
|
|
@@ -36,16 +36,16 @@
|
|
|
36
36
|
"body": { "enum": [true, false, "unknown"] },
|
|
37
37
|
"provenance": { "type": "string" },
|
|
38
38
|
"requestBodySchema": {
|
|
39
|
-
"description": "A2: the operation's request body projected from a real OpenAPI 3.1 document
|
|
39
|
+
"description": "A2: the operation's request body projected from a real OpenAPI 3.1 document -- see contracts/openapi.mjs's inlineSchema(). Fully inlined (no $ref) EXCEPT at a genuinely self-referential component (D-openapi-cyclic-refs), where a $ref/$defs pair is used instead -- see this schema's own top-level $defs when present. Present only when reconciliation matched/adopted this operation AND its application/json schema resolved; omitted otherwise. Not deeply validated as 'is this a valid JSON Schema' here -- that would need a schema-of-schemas, out of scope.",
|
|
40
40
|
"type": "object"
|
|
41
41
|
},
|
|
42
42
|
"requestBodyRequired": { "type": "boolean" },
|
|
43
43
|
"responseSchema": {
|
|
44
|
-
"description": "A3: all documented 2xx application/json response schemas for this operation, fully inlined (no $ref); an anyOf union when 2+ distinct shapes are documented. Present only when matched/adopted AND at least one resolved.",
|
|
44
|
+
"description": "A3: all documented 2xx application/json response schemas for this operation, fully inlined (no $ref) EXCEPT at a genuinely self-referential component (D-openapi-cyclic-refs, $ref/$defs used instead); an anyOf union when 2+ distinct shapes are documented. Present only when matched/adopted AND at least one resolved.",
|
|
45
45
|
"type": "object"
|
|
46
46
|
},
|
|
47
47
|
"errorSchema": {
|
|
48
|
-
"description": "A3: all documented 4xx/5xx application/json response schemas for this operation, fully inlined (no $ref); an anyOf union when 2+ distinct shapes are documented. Present only when matched/adopted AND at least one resolved.",
|
|
48
|
+
"description": "A3: all documented 4xx/5xx application/json response schemas for this operation, fully inlined (no $ref) EXCEPT at a genuinely self-referential component (D-openapi-cyclic-refs, $ref/$defs used instead); an anyOf union when 2+ distinct shapes are documented. Present only when matched/adopted AND at least one resolved.",
|
|
49
49
|
"type": "object"
|
|
50
50
|
},
|
|
51
51
|
"sourceParameters": {
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
"type": { "type": "string" },
|
|
21
21
|
"table": { "type": ["string", "null"] },
|
|
22
22
|
"idField": { "type": ["string", "null"] },
|
|
23
|
+
"idFieldType": { "type": ["string", "null"], "description": "D-write-safety-phase1 (item 4a): java-spring only -- the primary key's declared Java type (e.g. 'UUID', 'Integer'). null when it couldn't be determined at all (not the same as a confirmed non-UUID type)." },
|
|
24
|
+
"idFieldIsUuid": { "type": ["boolean", "null"], "description": "D-write-safety-phase1 (item 4a): mirrors typescript-express's own already-established field of the same name. false means a resolver is structurally impossible for this entity (the handles subsystem is UUID-addressable only), independent of whether a service file can be found." },
|
|
23
25
|
"readPath": { "type": ["string", "null"] },
|
|
24
26
|
"requiredAuthority": { "type": "string" },
|
|
25
27
|
"requiredAuthorityForPatch": { "type": "string", "description": "O5 (D-resolver-authorization-action-aware): java-spring only -- derived independently from the entity's UPDATE endpoint, not copied from requiredAuthority (which is fetch/recover's own value)." },
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:sbf:oracle-manifest:1",
|
|
4
|
+
"title": "backend-skeleton scanner-adapter verification-corpus manifest",
|
|
5
|
+
"description": "ROADMAP.md Phase 5b (D-oracle-corpus-pinning): the committed, human-readable record of real, third-party repos pinned to a real commit SHA per scanner adapter, driven by scripts/shadow-validation-smoke.mjs --manifest. Loaded at scripts/shadow-validation-smoke.mjs run time, not by the CLI itself -- this is a test/verification-only artifact, never read by bin/bskel.mjs.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["contract", "adapters"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"contract": { "const": "sbf.oracle-manifest/1" },
|
|
11
|
+
"adapters": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"propertyNames": {
|
|
14
|
+
"enum": ["java-spring", "python-fastapi", "typescript-express", "javascript-express", "generic-grep"]
|
|
15
|
+
},
|
|
16
|
+
"additionalProperties": {
|
|
17
|
+
"type": "array",
|
|
18
|
+
"minItems": 1,
|
|
19
|
+
"items": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"required": ["id", "repo", "terms", "note"],
|
|
23
|
+
"properties": {
|
|
24
|
+
"id": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"pattern": "^[a-z][a-z0-9-]*$",
|
|
27
|
+
"description": "Short, unique-within-this-adapter identifier for this manifest entry (e.g. 'spring-petclinic') -- used in report output, never sent to git/GitHub."
|
|
28
|
+
},
|
|
29
|
+
"owner": {
|
|
30
|
+
"type": ["string", "null"],
|
|
31
|
+
"description": "null means \"repo\" is used as-is as a literal clone URL/local path (matches scripts/shadow-validation-smoke.mjs's own parseRepoSpec literal form) -- ONLY for local, non-network test fixtures (test/shadow-validation-cli.test.mjs); every real corpus entry in this file names a real owner."
|
|
32
|
+
},
|
|
33
|
+
"repo": { "type": "string", "minLength": 1 },
|
|
34
|
+
"ref": {
|
|
35
|
+
"type": ["string", "null"],
|
|
36
|
+
"pattern": "^([0-9a-f]{40})?$",
|
|
37
|
+
"description": "A real, pinned commit SHA -- ROADMAP.md Phase 5b's own 'pin refs' requirement. null is permitted only for the local-fixture literal-owner form above (no meaningful \"pin\" for a throwaway local bare repo). Never a branch/tag name here (scripts/shadow-validation-smoke.mjs's own DEFAULT_REPOS/CLI-spec forms still accept branch names for quick manual use; this committed manifest does not)."
|
|
38
|
+
},
|
|
39
|
+
"path": {
|
|
40
|
+
"type": ["string", "null"],
|
|
41
|
+
"description": "Subdirectory within the clone to scope every bskel invocation to (relative, no leading/trailing slash) -- null/absent means the clone root. Needed for monorepos where the actual backend lives under a subdirectory (e.g. polarsource/polar's 'server')."
|
|
42
|
+
},
|
|
43
|
+
"terms": {
|
|
44
|
+
"type": "array",
|
|
45
|
+
"minItems": 1,
|
|
46
|
+
"items": { "type": "string", "minLength": 1 }
|
|
47
|
+
},
|
|
48
|
+
"note": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"minLength": 1,
|
|
51
|
+
"description": "One-line, human-readable justification for why this repo is in the corpus (what real coverage it adds, or what real diagnostic gap it's expected to surface) -- see DECISIONS.md's D-oracle-corpus-pinning for the full record."
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
"items": { "type": "string" }
|
|
16
16
|
},
|
|
17
17
|
"env_example_keys": { "type": "array", "items": { "type": "string" } },
|
|
18
|
-
"at": { "type": "string", "format": "date-time" }
|
|
18
|
+
"at": { "type": "string", "format": "date-time" },
|
|
19
|
+
"file_hashes": {
|
|
20
|
+
"description": "D-write-safety-phase0 (item 2): additive, optional (absent on a record written before this existed -- planApply() treats a missing entry as no prior provenance, same as classifyFile()'s own no-manifest-entry fallback). sha256 of what `stack apply --apply` itself last wrote to each path in applied_files, keyed by that same relative path -- gives planApply() a preimage to check a hand-edit against, instead of only comparing to the current fresh render.",
|
|
21
|
+
"type": "object",
|
|
22
|
+
"additionalProperties": { "type": "string" }
|
|
23
|
+
}
|
|
19
24
|
}
|
|
20
25
|
}
|
package/stack/apply.mjs
CHANGED
|
@@ -6,6 +6,13 @@ import Ajv2020 from 'ajv/dist/2020.js';
|
|
|
6
6
|
// P2b (D-greenfield-parameters): was a private `renderTemplate(templatePath, vars)` here, moved to
|
|
7
7
|
// lib/template.mjs unchanged once `new/fastapi.mjs` became its second real consumer.
|
|
8
8
|
import { renderTemplateFile } from '../lib/template.mjs';
|
|
9
|
+
// D-write-safety-phase0 (item 2): reusing the exact same provenance-based classification and
|
|
10
|
+
// git-recoverability check the handles write path already established, rather than inventing a
|
|
11
|
+
// second one for this write path.
|
|
12
|
+
import { classifyFile } from '../lib/handles-manifest.mjs';
|
|
13
|
+
import { isDirtyOrUntracked } from '../handles/_engine.mjs';
|
|
14
|
+
import { sha256String, readJsonIfExists } from '../lib/fsutil.mjs';
|
|
15
|
+
import { sbfPath } from '../lib/paths.mjs';
|
|
9
16
|
|
|
10
17
|
const STACK_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
11
18
|
const SCHEMAS_ROOT = path.join(STACK_ROOT, '..', 'schemas');
|
|
@@ -84,19 +91,31 @@ export function planApply(repoRoot, entry, { port = 8080 } = {}) {
|
|
|
84
91
|
// crossed the stated boundary).
|
|
85
92
|
plan.alreadyDetected = (entry.detect?.files ?? []).some((f) => fs.existsSync(path.join(repoRoot, f)));
|
|
86
93
|
|
|
94
|
+
// D-write-safety-phase0 (item 2): `file_hashes` (additive, schemas/stack-record.schema.json) is
|
|
95
|
+
// what `stack apply` itself last wrote to each path -- absent on a record from before this
|
|
96
|
+
// existed, or if `stack apply` never ran. classifyFile()'s own no-manifest-entry fallback
|
|
97
|
+
// (content-comparison only) covers that case exactly the way handles emit's first-ever run does.
|
|
98
|
+
const priorRecord = readJsonIfExists(sbfPath(repoRoot, 'stack.json'));
|
|
99
|
+
const priorHashes = priorRecord?.file_hashes ?? {};
|
|
100
|
+
|
|
87
101
|
for (const f of entry.static?.files ?? []) {
|
|
88
102
|
const templatePath = path.join(STACK_ROOT, f.template);
|
|
89
103
|
assertContained(STACK_ROOT, templatePath, 'catalog template path');
|
|
90
104
|
const targetPath = path.join(repoRoot, f.path);
|
|
91
105
|
assertContained(repoRoot, targetPath, 'catalog target path');
|
|
92
106
|
const rendered = renderTemplateFile(templatePath, { PORT: port });
|
|
93
|
-
const
|
|
94
|
-
const
|
|
107
|
+
const diskContent = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, 'utf8') : null;
|
|
108
|
+
const exists = diskContent !== null;
|
|
109
|
+
const diskHash = exists ? sha256String(diskContent) : null;
|
|
110
|
+
const freshRenderHash = sha256String(rendered);
|
|
111
|
+
const matchesPristineRender = exists && diskContent === rendered;
|
|
112
|
+
const action = classifyFile({ exists, diskHash, manifestEntryHash: priorHashes[f.path] ?? null, freshRenderHash, matchesPristineRender });
|
|
95
113
|
plan.files.push({
|
|
96
114
|
path: f.path,
|
|
97
115
|
mode: f.mode ?? null,
|
|
98
|
-
action
|
|
116
|
+
action,
|
|
99
117
|
content: rendered,
|
|
118
|
+
contentHash: freshRenderHash,
|
|
100
119
|
});
|
|
101
120
|
}
|
|
102
121
|
|
|
@@ -133,18 +152,40 @@ export function planApply(repoRoot, entry, { port = 8080 } = {}) {
|
|
|
133
152
|
// API supports this), config_check could gain an `apply` action -- not built now because the
|
|
134
153
|
// real target (Team-IZ-Backend) doesn't need it (already externalized), so there's no concrete
|
|
135
154
|
// case to validate a patcher against yet.
|
|
136
|
-
|
|
155
|
+
// D-write-safety-phase0 (item 2): `force` mirrors handles emit's own `--force` gate exactly -- a
|
|
156
|
+
// `conflict` file (diverged from what `stack apply` itself last wrote) is refused outright without
|
|
157
|
+
// it, and even with it is refused if not git-recoverable (uncommitted/untracked), so a --force
|
|
158
|
+
// overwrite is only ever reversible. The `--reason` a real overwrite requires is a CLI-layer
|
|
159
|
+
// concern (validated in cmdStackApply, mirroring cmdContractWaive/handles emit's identical
|
|
160
|
+
// pattern) -- applyPlan() itself has nothing to do with an audit string it never persists. Returns
|
|
161
|
+
// `fileHashes` (sha256 of what was ACTUALLY written this run) so the caller can persist it into
|
|
162
|
+
// `.sbf/stack.json`'s new `file_hashes` field -- unchanged/adopt-unchanged files are simply absent
|
|
163
|
+
// here, so the caller must merge onto the PRIOR record's file_hashes, not replace it wholesale.
|
|
164
|
+
export function applyPlan(repoRoot, plan, { force = false } = {}) {
|
|
137
165
|
const written = [];
|
|
166
|
+
const conflicts = [];
|
|
167
|
+
const fileHashes = {};
|
|
138
168
|
for (const f of plan.files) {
|
|
139
|
-
if (f.action === 'unchanged') continue;
|
|
169
|
+
if (f.action === 'unchanged' || f.action === 'adopt-unchanged') continue;
|
|
140
170
|
const targetPath = path.join(repoRoot, f.path);
|
|
141
171
|
// Re-asserted here too (planApply already checked it) -- applyPlan must not assume it's
|
|
142
172
|
// only ever called with a plan it just generated for the same repoRoot.
|
|
143
173
|
assertContained(repoRoot, targetPath, 'catalog target path');
|
|
174
|
+
if (f.action === 'conflict') {
|
|
175
|
+
if (!force) {
|
|
176
|
+
conflicts.push({ path: f.path, reason: 'diverged from the last content `bskel stack apply` generated -- see notes for remediation' });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (isDirtyOrUntracked(repoRoot, targetPath)) {
|
|
180
|
+
conflicts.push({ path: f.path, reason: 'refusing --force: this file has uncommitted/untracked changes -- commit or stash it first so the overwrite is recoverable' });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
144
184
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
145
185
|
fs.writeFileSync(targetPath, f.content);
|
|
146
186
|
if (f.mode) fs.chmodSync(targetPath, Number.parseInt(f.mode, 8));
|
|
147
187
|
written.push(f.path);
|
|
188
|
+
fileHashes[f.path] = f.contentHash;
|
|
148
189
|
}
|
|
149
190
|
|
|
150
191
|
const toAppend = plan.envExampleActions.filter((a) => a.action === 'append');
|
|
@@ -158,5 +199,5 @@ export function applyPlan(repoRoot, plan) {
|
|
|
158
199
|
written.push('.env.example');
|
|
159
200
|
}
|
|
160
201
|
|
|
161
|
-
return written;
|
|
202
|
+
return { written, conflicts, fileHashes };
|
|
162
203
|
}
|