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.
Files changed (50) hide show
  1. package/README.md +66 -4
  2. package/bin/bskel.mjs +125 -18
  3. package/contracts/export.mjs +39 -4
  4. package/contracts/openapi.mjs +292 -27
  5. package/contracts/validate.mjs +23 -4
  6. package/handles/_engine.mjs +75 -32
  7. package/handles/capability-codec.mjs +94 -0
  8. package/handles/codec.mjs +13 -3
  9. package/handles/providers/java-spring/emit.mjs +78 -33
  10. package/handles/providers/java-spring/observe.mjs +4 -3
  11. package/handles/providers/java-spring/plan.mjs +51 -7
  12. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
  13. package/handles/providers/java-spring/templates/HandleController.java.tmpl +13 -7
  14. package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
  15. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
  16. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +24 -3
  17. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
  18. package/handles/providers/java-spring.mjs +8 -0
  19. package/handles/providers/python-fastapi/emit.mjs +21 -26
  20. package/handles/providers/python-fastapi/observe.mjs +6 -5
  21. package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
  22. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +32 -4
  23. package/handles/providers/python-fastapi.mjs +3 -3
  24. package/handles/providers/typescript-express/emit.mjs +135 -46
  25. package/handles/providers/typescript-express/observe.mjs +7 -6
  26. package/handles/providers/typescript-express/plan.mjs +8 -1
  27. package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
  28. package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
  29. package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
  30. package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
  31. package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
  32. package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
  33. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
  34. package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
  35. package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
  36. package/handles/providers/typescript-express.mjs +7 -4
  37. package/lib/cli.mjs +11 -2
  38. package/lib/exit-codes.mjs +21 -0
  39. package/lib/verify.mjs +23 -6
  40. package/package.json +5 -2
  41. package/scanners/adapters/_java-spring-analyzer.mjs +25 -2
  42. package/scanners/adapters/java-spring.mjs +108 -10
  43. package/scanners/adapters/javascript-express.mjs +46 -13
  44. package/scanners/adapters/python-fastapi.mjs +63 -18
  45. package/scanners/adapters/typescript-express.mjs +33 -7
  46. package/schemas/feature-contract.schema.json +3 -3
  47. package/schemas/handles-plan.schema.json +2 -0
  48. package/schemas/oracle-manifest.schema.json +58 -0
  49. package/schemas/stack-record.schema.json +6 -1
  50. package/stack/apply.mjs +47 -6
@@ -226,10 +226,40 @@ function resolveEsmImport(fromFile, specifier, suffixes) {
226
226
  return null;
227
227
  }
228
228
 
229
- // `export default router;` -- which locally-declared mountable a file hands to whoever imports it.
230
- function defaultExportedMountable(text, mountables) {
231
- const m = text.match(/export\s+default\s+([\w$]+)\s*;?/);
232
- return m && mountables.has(m[1]) ? m[1] : null;
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 importRe = new RegExp(`import\\s+${target}\\s*(?:,\\s*\\{[^}]*\\})?\\s*from\\s*["']([^"']+)["']`);
265
- const importMatch = info.text.match(importRe);
266
- if (!importMatch) continue;
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 = defaultExportedMountable(toInfo.text, toInfo.mountables);
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-adapter-verification-basis: no real-world oracle at all, unlike every other real framework
395
- // adapter -- the real target repository was deliberately never touched, and the committed
396
- // synthetic fixture carries all of the regression weight. Named honestly, not hidden.
397
- verificationBasis: 'synthetic-only',
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 = /@\w+\.(get|post|put|patch|delete)\s*\(/gi;
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
- function extractBasePath(text) {
108
- const m = text.match(/APIRouter\s*\(/);
109
- if (!m) return '';
110
- const openIdx = m.index + m[0].length - 1;
111
- const closeIdx = matchBalancedParens(text, openIdx);
112
- if (closeIdx === -1) return '';
113
- const prefixMatch = text.slice(openIdx + 1, closeIdx).match(/prefix\s*=\s*["']([^"']*)["']/);
114
- return prefixMatch ? prefixMatch[1] : '';
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 verb = m[1].toUpperCase();
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 extractBasePath); FastAPI generates operation ids
253
- // at runtime, never pinned in source -- see extractEndpoints. --openapi-file + --path-prefix is the
254
- // trustworthy path (contracts/openapi.mjs's existing, adapter-agnostic reconciliation).
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 basePath = extractBasePath(text);
281
- const endpoints = extractEndpoints(text).map((ep) => ({ ...ep, path: joinPath(basePath, ep.path) }));
282
- if (endpoints.length > 0) {
283
- moduleEntry(moduleName).controllers.push({ className: `${capitalize(moduleName)}Router`, basePath, operationIds: [], endpoints, file });
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
- if (!handlerMatch) continue;
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, fully inlined (no $ref) -- see contracts/openapi.mjs's inlineSchema(). 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.",
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 exists = fs.existsSync(targetPath);
94
- const unchanged = exists && fs.readFileSync(targetPath, 'utf8') === rendered;
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: !exists ? 'create' : (unchanged ? 'unchanged' : 'update'),
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
- export function applyPlan(repoRoot, plan) {
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
  }