backend-skeleton 1.0.0-beta.2 → 1.0.0-beta.4

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 (38) hide show
  1. package/README.md +12 -2
  2. package/bin/bskel.mjs +260 -11
  3. package/contracts/completeness.mjs +27 -2
  4. package/contracts/emit.mjs +68 -9
  5. package/contracts/export.mjs +94 -25
  6. package/contracts/openapi.mjs +307 -40
  7. package/handles/audit.mjs +83 -0
  8. package/handles/codec.mjs +11 -5
  9. package/handles/providers/java-spring/emit.mjs +9 -2
  10. package/handles/providers/java-spring/plan.mjs +20 -0
  11. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +13 -5
  12. package/handles/providers/java-spring/templates/HandleController.java.tmpl +54 -20
  13. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +17 -1
  14. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +5 -0
  15. package/handles/providers/java-spring.mjs +2 -2
  16. package/handles/providers/python-fastapi/emit.mjs +5 -1
  17. package/handles/providers/python-fastapi/templates/codec.py.tmpl +9 -1
  18. package/handles/providers/python-fastapi/templates/router.py.tmpl +43 -19
  19. package/handles/providers/typescript-express/templates/codec.ts.tmpl +10 -1
  20. package/lib/cli.mjs +51 -3
  21. package/lib/handles-manifest.mjs +10 -4
  22. package/lib/repo.mjs +56 -0
  23. package/package.json +1 -1
  24. package/scanners/adapters/_express-shared.mjs +17 -12
  25. package/scanners/adapters/generic-grep.mjs +5 -1
  26. package/scanners/adapters/java-spring.mjs +60 -14
  27. package/scanners/adapters/javascript-express.mjs +5 -1
  28. package/scanners/adapters/python-fastapi.mjs +17 -14
  29. package/scanners/adapters/typescript-express.mjs +6 -1
  30. package/scanners/registry.mjs +5 -2
  31. package/scanners/text-util.mjs +25 -0
  32. package/schemas/adapter.schema.json +6 -2
  33. package/schemas/contract-resolution.schema.json +6 -1
  34. package/schemas/feature-contract.schema.json +10 -1
  35. package/schemas/handles-plan.schema.json +1 -0
  36. package/stack/bootstrap/db-up.sh +52 -0
  37. package/stack/bootstrap/docker-compose.postgres.yml +18 -0
  38. package/stack/catalog/postgres-dev-db.yml +55 -0
@@ -16,21 +16,27 @@ export function manifestPath(root) {
16
16
 
17
17
  export function loadManifest(root) {
18
18
  const parsed = readJsonIfExists(manifestPath(root));
19
- if (!parsed) return { schema: MANIFEST_SCHEMA, files: {} };
19
+ if (!parsed) return { schema: MANIFEST_SCHEMA, files: {}, enforceRegistry: false };
20
20
  if (parsed.schema !== MANIFEST_SCHEMA) {
21
21
  throw new Error(`${manifestPath(root)}: unrecognized handles-manifest schema "${parsed.schema}" (expected ${MANIFEST_SCHEMA})`);
22
22
  }
23
- return parsed;
23
+ // O3 (D-handle-registry-enforcement): additive field, no schema bump -- a manifest written
24
+ // before this existed simply has no key here, and `false` is exactly its correct historical
25
+ // value (registry enforcement did not exist yet, so it was never "on").
26
+ return { enforceRegistry: false, ...parsed };
24
27
  }
25
28
 
26
29
  // Sorted keys for stable diffs if a target repo commits this file. Callers are responsible for
27
30
  // only calling this when something in `manifest.files` actually changed -- classifyFile()'s
28
31
  // 'unchanged' action means "don't touch the manifest entry", not "resave it unchanged", so a
29
32
  // true no-op re-emit leaves this file byte-identical (see test/handles-manifest.test.mjs and the
30
- // e2e regression in test/handles-ownership-cli.test.mjs).
33
+ // e2e regression in test/handles-ownership-cli.test.mjs). `enforceRegistry` (O3,
34
+ // D-handle-registry-enforcement) is repo-wide, singleton state -- the same "infra is one
35
+ // all-or-nothing unit" scope O2's own `files` entries already give `global/handle/*` -- so it is
36
+ // always written, never omitted, even when `false` (the default).
31
37
  export function saveManifest(root, manifest) {
32
38
  const sortedFiles = Object.fromEntries(Object.keys(manifest.files).sort().map((k) => [k, manifest.files[k]]));
33
- writeFileAtomic(manifestPath(root), `${JSON.stringify({ schema: manifest.schema, files: sortedFiles }, null, 2)}\n`);
39
+ writeFileAtomic(manifestPath(root), `${JSON.stringify({ schema: manifest.schema, files: sortedFiles, enforceRegistry: manifest.enforceRegistry ?? false }, null, 2)}\n`);
34
40
  }
35
41
 
36
42
  // Every resolver stub's doc comment carries "... for feature <id>." -- ResourceResolverStub.java
package/lib/repo.mjs CHANGED
@@ -42,3 +42,59 @@ export function remoteTrackingTip(cwd = process.cwd(), branch) {
42
42
  return null;
43
43
  }
44
44
  }
45
+
46
+ // D-contract-history: the chronological commit list touching one file, oldest first (reversed
47
+ // from git's own newest-first `log` order -- a caller building a diff-over-time view wants to
48
+ // walk forward). `--follow` survives a rename (specs/<feature>/ is renamed wholesale by `bskel
49
+ // feature rename`, see D6). Empty array (not an error) when the path was never committed --
50
+ // `specs/` is a TARGET repo's own file, entirely outside bskel's control whether it's tracked at
51
+ // all, so an empty result is a normal, expected outcome here, not a failure.
52
+ export function fileHistory(cwd, relPath) {
53
+ let raw;
54
+ try {
55
+ raw = git(['log', '--follow', '--format=%H%x1f%aI%x1f%s', '--', relPath], cwd);
56
+ } catch {
57
+ return [];
58
+ }
59
+ if (!raw) return [];
60
+ return raw.split('\n').map((line) => {
61
+ const [sha, date, subject] = line.split('\x1f');
62
+ return { sha, date, subject };
63
+ }).reverse();
64
+ }
65
+
66
+ // The file's exact content at one historical revision -- `git show <sha>:<path>`, not a working-
67
+ // tree read, so this is safe to call across arbitrary history without touching the checkout.
68
+ // Returns null (not a throw) when the path didn't exist at that revision, matching fileHistory()'s
69
+ // own "absence is a normal outcome, not a failure" posture.
70
+ export function showFileAtRevision(cwd, sha, relPath) {
71
+ try {
72
+ return git(['show', `${sha}:${relPath}`], cwd);
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // D-gate-export: the CURRENTLY checked-out branch name (`git rev-parse --abbrev-ref HEAD`) --
79
+ // deliberately distinct from `localDefaultBranch()` above, which answers "what does origin/HEAD
80
+ // point at," not "what is checked out right now." `HEAD` (detached) is a real, valid return value,
81
+ // not an error -- callers that care should check for it explicitly rather than this function
82
+ // papering over it.
83
+ export function currentBranch(cwd = process.cwd()) {
84
+ try {
85
+ return git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ // D-gate-export: whether the working tree has any uncommitted change (`git status --porcelain`
92
+ // non-empty) -- provenance context for a report that claims to describe "what this repo's gate
93
+ // history shows," since an uncommitted change means the working tree no longer matches HEAD.
94
+ export function isDirty(cwd = process.cwd()) {
95
+ try {
96
+ return git(['status', '--porcelain'], cwd).length > 0;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.4",
4
4
  "type": "module",
5
5
  "description": "Spec-driven backend scaffolding: brownfield-scan gate, feature_id-keyed contracts, UUID bidirectional handles, stack-choice wiring.",
6
6
  "license": "AGPL-3.0-or-later",
@@ -15,6 +15,7 @@
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import { execFileSync } from 'node:child_process';
18
+ import { listRgFiles as sharedListRgFiles, byShallowestThenName } from '../text-util.mjs';
18
19
 
19
20
  export const EXCLUDE_GLOBS = ['!**/node_modules/**', '!**/dist/**', '!**/build/**'];
20
21
  export const VERBS = ['get', 'post', 'put', 'patch', 'delete'];
@@ -24,13 +25,13 @@ export const VERBS = ['get', 'post', 'put', 'patch', 'delete'];
24
25
  // plain path with no interpolation, not a regular string.
25
26
  export const STRING_LITERAL_RE = /^\s*["'`]([^"'`]*)["'`]/;
26
27
 
28
+ // D-cross-adapter-root-detection: listRgFiles/byShallowestThenName themselves now live in
29
+ // scanners/text-util.mjs (shared with python-fastapi.mjs/java-spring.mjs too) -- this file keeps
30
+ // its own thin wrapper so every existing call site here keeps passing just (dir, globs), with
31
+ // EXCLUDE_GLOBS applied automatically the same way it always was.
32
+ export { byShallowestThenName };
27
33
  export function listRgFiles(dir, globs) {
28
- try {
29
- const out = execFileSync('rg', ['--files', ...globs.flatMap((g) => ['-g', g]), ...EXCLUDE_GLOBS.flatMap((g) => ['-g', g]), dir], { encoding: 'utf8' });
30
- return out.split('\n').filter(Boolean).sort(); // O6: rg --files order isn't guaranteed.
31
- } catch {
32
- return []; // rg exits 1 on "no files matched" -- not an error, just nothing to report
33
- }
34
+ return sharedListRgFiles(dir, globs, EXCLUDE_GLOBS);
34
35
  }
35
36
 
36
37
  // `rg -l -e <pattern> -g <glob>...` -- the "which source files even mention this" pass both
@@ -47,12 +48,6 @@ export function rgFilesMatching(pattern, globs, dir) {
47
48
  }
48
49
  }
49
50
 
50
- export function byShallowestThenName(a, b) {
51
- const depthA = a.split(path.sep).length;
52
- const depthB = b.split(path.sep).length;
53
- return depthA !== depthB ? depthA - depthB : a.localeCompare(b);
54
- }
55
-
56
51
  export function listCandidatePackageFiles(repoRoot) {
57
52
  return listRgFiles(repoRoot, ['package.json']).sort(byShallowestThenName);
58
53
  }
@@ -234,5 +229,15 @@ export function expressDiagnostics(repoRoot) {
234
229
  if (!rgOk) {
235
230
  messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter shells out to it and will throw, not degrade, if it is missing' });
236
231
  }
232
+ // D-openapi-extraction-hint: like FastAPI, both Express adapters declare `api.operations:
233
+ // false` -- --openapi-file is load-bearing for `contract emit` to adopt any operation, not just
234
+ // an accuracy improvement. Unlike java-spring/python-fastapi, plain Express has NO framework-
235
+ // native OpenAPI generation mechanism at all -- said plainly, not papered over with vague
236
+ // "check your framework's docs" language. If the target project already uses a third-party
237
+ // generator (swagger-jsdoc, tsoa, zod-to-openapi, etc.), that tool's own output is what to pass.
238
+ messages.push({
239
+ level: 'info', code: 'openapi-extraction-hint',
240
+ message: '--openapi-file is not just an accuracy improvement for this adapter -- api.operations is false (no operationId concept in plain Express), so `contract emit` cannot adopt any operation without one. Plain Express has NO framework-native OpenAPI generation -- if this project already uses a third-party generator (swagger-jsdoc, tsoa, zod-to-openapi, etc.), pass that tool\'s own output. Ignore this if you already have a source document.',
241
+ });
237
242
  return messages;
238
243
  }
@@ -101,11 +101,15 @@ export function scanGenericGrep(repoRoot) {
101
101
  // regardless of what generic-grep itself finds. See D-generic-grep-reconnaissance for why every
102
102
  // capability below is honestly false rather than a best-effort partial implementation.
103
103
  export const adapter = {
104
- contract: 'sbf.adapter/1',
104
+ contract: 'sbf.adapter/2',
105
105
  id: 'generic-grep',
106
106
  title: 'Generic route-pattern grep (fallback)',
107
107
  specificity: 0,
108
108
  confidence: 'low',
109
+ // D-adapter-verification-basis: reconnaissance-only by design (G3) -- every codegen capability
110
+ // is honestly false, so there is no codegen output to verify against any oracle at all. Not
111
+ // "unverified" in the sense the other four values mean; genuinely not applicable.
112
+ verificationBasis: 'not-applicable',
109
113
  capabilities: {
110
114
  'api.operations': false,
111
115
  'api.request-shape': false,
@@ -5,14 +5,35 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { execFileSync } from 'node:child_process';
8
- import { lineNumberAt } from '../text-util.mjs';
8
+ import { lineNumberAt, listRgFiles, byShallowestThenName } from '../text-util.mjs';
9
9
  import { maskNonCode, findClassOrRecordDeclaration, findClassLevelMappingArgs, findMappingAnnotations } from './_java-spring-analyzer.mjs';
10
10
 
11
+ const JAVA_BUILD_FILE_GLOBS = ['build.gradle', 'build.gradle.kts', 'pom.xml'];
12
+
13
+ // D-cross-adapter-root-detection: until now, the ONLY one of this project's 4 real adapters whose
14
+ // detect() assumed repoRoot itself was the project root -- python-fastapi.mjs/typescript-express.mjs/
15
+ // javascript-express.mjs each already walk the WHOLE repo for their own project marker file
16
+ // (python-fastapi.mjs's own comment names the exact reason: "a real target [oracle] is a monorepo
17
+ // whose FastAPI project lives under backend/"). A Spring project nested the same way (e.g.
18
+ // backend-java/build.gradle in a polyglot monorepo) was silently invisible to this adapter --
19
+ // found live by direct comparison of all 4 adapters' detect() functions, not anticipated
20
+ // defensively. `runScan()`'s specificity-based arbitration (see scanners/index.mjs and
21
+ // python-fastapi.mjs's own specificity comment) already handles the "two adapters both detect the
22
+ // same repoRoot" case deterministically; this fix addresses the OTHER failure mode -- a real
23
+ // Spring project going completely undetected and silently falling through to generic-grep.
24
+ //
25
+ // Every candidate build file is tried in turn, shallowest first, not just the first one found
26
+ // (unlike python-fastapi's single-candidate check) -- a real multi-module Gradle project's ROOT
27
+ // build.gradle is routinely just an aggregator (`subprojects { ... }`) with no src/main/java of
28
+ // its own; the actual source lives under a child module's own build.gradle. Same two-signal bar
29
+ // as before either way: a build file AND a sibling src/main/java, both required.
11
30
  export function detectJavaSpringRoot(repoRoot) {
12
- const buildFiles = ['build.gradle', 'build.gradle.kts', 'pom.xml'];
13
- if (!buildFiles.some((f) => fs.existsSync(path.join(repoRoot, f)))) return null;
14
- const srcRoot = path.join(repoRoot, 'src', 'main', 'java');
15
- return fs.existsSync(srcRoot) ? srcRoot : null;
31
+ const buildFiles = listRgFiles(repoRoot, JAVA_BUILD_FILE_GLOBS).sort(byShallowestThenName);
32
+ for (const buildFile of buildFiles) {
33
+ const srcRoot = path.join(path.dirname(buildFile), 'src', 'main', 'java');
34
+ if (fs.existsSync(srcRoot)) return srcRoot;
35
+ }
36
+ return null;
16
37
  }
17
38
 
18
39
  // O6: `rg --files` (no `--sort`) is explicitly unordered/parallel by ripgrep's own docs -- two
@@ -167,8 +188,12 @@ export function detectGlobalPathPrefixSignals(repoRoot) {
167
188
  // mentioning configurePathMatch, then a content read confirms addPathPrefix( is actually
168
189
  // present (a configurePathMatch override that does something else entirely -- a custom
169
190
  // PathMatcher, a trailing-slash setting -- must not be reported as a prefix).
170
- const srcRoot = path.join(repoRoot, 'src', 'main', 'java');
171
- if (fs.existsSync(srcRoot)) {
191
+ // D-cross-adapter-root-detection: reuses detectJavaSpringRoot() rather than re-deriving
192
+ // `path.join(repoRoot, 'src', 'main', 'java')` independently -- the latter was a SECOND place
193
+ // this file assumed repoRoot itself was the project root, found alongside the same bug in
194
+ // detectJavaSpringRoot() itself.
195
+ const srcRoot = detectJavaSpringRoot(repoRoot);
196
+ if (srcRoot) {
172
197
  let candidates = [];
173
198
  try {
174
199
  candidates = execFileSync('rg', ['-l', 'configurePathMatch', '-g', '*.java', srcRoot], { encoding: 'utf8' }).split('\n').filter(Boolean).sort();
@@ -250,11 +275,15 @@ export function scanJavaSpring(repoRoot) {
250
275
  // build file AND src/main/java both exist -- a build-file-plus-source-layout-confirmed match, the
251
276
  // strongest signal any adapter in this codebase can give.
252
277
  export const adapter = {
253
- contract: 'sbf.adapter/1',
278
+ contract: 'sbf.adapter/2',
254
279
  id: 'java-spring',
255
280
  title: 'Java / Spring Boot',
256
281
  specificity: 100,
257
282
  confidence: 'high',
283
+ // D-adapter-verification-basis: the whole A1-A12 OpenAPI-passthrough line, plus O1-O6's
284
+ // handles codegen, was verified against a real production Spring Boot repo (Team-IZ-Backend) --
285
+ // the strongest basis any adapter in this codebase has.
286
+ verificationBasis: 'production-repo',
258
287
  capabilities: {
259
288
  'api.operations': true,
260
289
  'api.request-shape': true,
@@ -278,13 +307,17 @@ export const adapter = {
278
307
  },
279
308
  diagnostics(repoRoot) {
280
309
  const messages = [];
281
- const buildFiles = ['build.gradle', 'build.gradle.kts', 'pom.xml'];
282
- if (!buildFiles.some((f) => fs.existsSync(path.join(repoRoot, f)))) {
283
- messages.push({ level: 'info', code: 'no-build-file', message: `none of ${buildFiles.join(', ')} found at repo root` });
310
+ // D-cross-adapter-root-detection: recursive now (matches detectJavaSpringRoot() itself),
311
+ // not repoRoot-only -- these two checks used to have their own private, repoRoot-only copy
312
+ // of the same signals detectJavaSpringRoot() computes, which is exactly what let the
313
+ // underlying bug (a nested build.gradle going undetected) hide from `bskel doctor` too.
314
+ const buildFiles = listRgFiles(repoRoot, JAVA_BUILD_FILE_GLOBS);
315
+ if (buildFiles.length === 0) {
316
+ messages.push({ level: 'info', code: 'no-build-file', message: `none of ${JAVA_BUILD_FILE_GLOBS.join(', ')} found anywhere in the repo` });
284
317
  }
285
- const srcRoot = path.join(repoRoot, 'src', 'main', 'java');
286
- if (!fs.existsSync(srcRoot)) {
287
- messages.push({ level: 'info', code: 'no-src-main-java', message: 'src/main/java does not exist' });
318
+ const srcRoot = detectJavaSpringRoot(repoRoot);
319
+ if (!srcRoot) {
320
+ messages.push({ level: 'info', code: 'no-src-main-java', message: buildFiles.length > 0 ? 'found a build file, but none has a sibling src/main/java' : 'src/main/java does not exist' });
288
321
  } else {
289
322
  let rgOk = true;
290
323
  try {
@@ -296,6 +329,19 @@ export const adapter = {
296
329
  messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter shells out to it and will throw, not degrade, if it is missing' });
297
330
  }
298
331
  }
332
+ // D-openapi-extraction-hint: `contract emit --openapi-file` (A1-A12) is where real accuracy
333
+ // comes from for this adapter (path/schema/parameter/security correction, not just the
334
+ // name-heuristic fallback) -- most useful before that flag has ever been used, hence
335
+ // `level: 'info'`, not a warning. If springdoc-openapi is on the classpath: with the Gradle
336
+ // plugin (`org.springdoc.openapi-gradle-plugin`) configured, `./gradlew generateOpenApiDocs`
337
+ // boots the app briefly and writes build/api-docs.json without a human ever starting/curling
338
+ // a server by hand -- this repo's own real Team-IZ-Backend oracle file was produced exactly
339
+ // this way. Without that plugin, the only way is to actually run the app and capture its
340
+ // live /v3/api-docs endpoint -- named honestly as manual, not hidden behind vague language.
341
+ messages.push({
342
+ level: 'info', code: 'openapi-extraction-hint',
343
+ message: 'for real schema/path accuracy, pass --openapi-file to `contract emit`. If springdoc-openapi is on the classpath: with the Gradle plugin (org.springdoc.openapi-gradle-plugin) configured, `./gradlew generateOpenApiDocs` writes build/api-docs.json without running the app by hand; otherwise, run the app and capture its live /v3/api-docs endpoint (e.g. `curl http://localhost:8080/v3/api-docs > api-docs.json`). Ignore this if you already have a source document.',
344
+ });
299
345
  return messages;
300
346
  },
301
347
  };
@@ -382,7 +382,7 @@ export function scanJavaScriptExpress(repoRoot, detection) {
382
382
  // metadata and a working codegen provider), so it should win that overlap quietly rather than
383
383
  // tripping runScan()'s same-specificity ambiguity error. Checkable via `bskel doctor`.
384
384
  export const adapter = {
385
- contract: 'sbf.adapter/1',
385
+ contract: 'sbf.adapter/2',
386
386
  id: 'javascript-express',
387
387
  title: 'JavaScript / Express (ESM, no ORM)',
388
388
  specificity: 80,
@@ -391,6 +391,10 @@ export const adapter = {
391
391
  // walk), not how many capabilities it can offer. generic-grep is `low` because it has no module
392
392
  // inference and no prefix resolution at all -- this adapter has both.
393
393
  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',
394
398
  capabilities: {
395
399
  // false: plain Express has no operationId concept at all. --openapi-file is the honest path
396
400
  // forward for an app that has one; see CAPABILITY_SATISFIERS in scanners/capabilities.mjs.
@@ -12,7 +12,7 @@
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { execFileSync } from 'node:child_process';
15
- import { lineNumberAt } from '../text-util.mjs';
15
+ import { lineNumberAt, listRgFiles as sharedListRgFiles, byShallowestThenName } from '../text-util.mjs';
16
16
 
17
17
  const PROJECT_FILE_GLOBS = ['pyproject.toml', 'requirements*.txt'];
18
18
  const EXCLUDE_GLOBS = ['!**/.venv/**', '!**/site-packages/**', '!**/node_modules/**', '!**/__pycache__/**'];
@@ -28,18 +28,7 @@ const CLASS_RE = /^class\s+(\w+)\s*\(([^)]*)\)\s*:/gm;
28
28
  const INCLUDE_ROUTER_RE = /include_router\s*\(/g;
29
29
 
30
30
  function listRgFiles(dir, globs) {
31
- try {
32
- const out = execFileSync('rg', ['--files', ...globs.flatMap((g) => ['-g', g]), ...EXCLUDE_GLOBS.flatMap((g) => ['-g', g]), dir], { encoding: 'utf8' });
33
- return out.split('\n').filter(Boolean).sort(); // O6: rg --files order isn't guaranteed -- see java-spring.mjs's listJavaFiles.
34
- } catch {
35
- return []; // rg exits 1 on "no files matched" -- not an error, just nothing to report
36
- }
37
- }
38
-
39
- function byShallowestThenName(a, b) {
40
- const depthA = a.split(path.sep).length;
41
- const depthB = b.split(path.sep).length;
42
- return depthA !== depthB ? depthA - depthB : a.localeCompare(b);
31
+ return sharedListRgFiles(dir, globs, EXCLUDE_GLOBS);
43
32
  }
44
33
 
45
34
  function listCandidateProjectFiles(repoRoot) {
@@ -288,11 +277,15 @@ export function scanPythonFastApi(repoRoot, projectRoot) {
288
277
  // them has to win, and it must be the same one every time" -- not a functional gap on this side
289
278
  // anymore, a real, documented trade-off (see DECISIONS.md), checkable via `bskel doctor`.
290
279
  export const adapter = {
291
- contract: 'sbf.adapter/1',
280
+ contract: 'sbf.adapter/2',
292
281
  id: 'python-fastapi',
293
282
  title: 'Python / FastAPI',
294
283
  specificity: 90,
295
284
  confidence: 'high',
285
+ // D-adapter-verification-basis: verified against `fastapi/full-stack-fastapi-template`, the
286
+ // official FastAPI-author-maintained reference stack -- the strongest verification tier this
287
+ // project distinguishes (unlike java-spring's real-but-non-canonical production repo).
288
+ verificationBasis: 'official-reference',
296
289
  capabilities: {
297
290
  // false: FastAPI generates operation ids at runtime (per-project, sometimes customized) --
298
291
  // never statically derivable from source. --openapi-file is the honest path forward; see
@@ -343,6 +336,16 @@ export const adapter = {
343
336
  if (!rgOk) {
344
337
  messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter shells out to it and will throw, not degrade, if it is missing' });
345
338
  }
339
+ // D-openapi-extraction-hint: unlike java-spring, this adapter's own capabilities already
340
+ // declare `api.operations: false` (FastAPI assigns operationIds at runtime) -- --openapi-file
341
+ // isn't just an accuracy improvement here, it's load-bearing for `contract emit` to adopt any
342
+ // operation at all (see D-fastapi-adapter). FastAPI exposes its own OpenAPI schema directly
343
+ // via `app.openapi()` -- no server needs to actually be listening, just importing the app
344
+ // object is enough, the real mechanism this project's own oracle checks used.
345
+ messages.push({
346
+ level: 'info', code: 'openapi-extraction-hint',
347
+ message: '--openapi-file is not just an accuracy improvement for this adapter -- api.operations is false (FastAPI assigns operationIds at runtime), so `contract emit` cannot adopt any operation without one. No server needs to be running: `python -c "from app.main import app; import json; print(json.dumps(app.openapi()))" > api-docs.json` (adjust the import path to your own app module). Ignore this if you already have a source document.',
348
+ });
346
349
  return messages;
347
350
  },
348
351
  };
@@ -264,11 +264,16 @@ export function scanTypeScriptExpress(repoRoot, projectRoot) {
264
264
  // (not an inherent "TypeScript signals are weaker" claim) so a polyglot repo's adapter selection
265
265
  // stays deterministic, checkable via `bskel doctor`.
266
266
  export const adapter = {
267
- contract: 'sbf.adapter/1',
267
+ contract: 'sbf.adapter/2',
268
268
  id: 'typescript-express',
269
269
  title: 'TypeScript / Express / TypeORM',
270
270
  specificity: 85,
271
271
  confidence: 'high',
272
+ // D-adapter-verification-basis: no framework-maintained Express reference exists (confirmed by
273
+ // real research before this adapter was built) -- verified instead against the best-validated
274
+ // real community boilerplate found, `mkosir/typeorm-express-typescript`. Permanently weaker
275
+ // than java-spring/python-fastapi's own basis, named honestly rather than hidden.
276
+ verificationBasis: 'community-sample',
272
277
  capabilities: {
273
278
  // false: plain Express has no operationId concept at all. --openapi-file is the honest path
274
279
  // forward for an app that has one; see CAPABILITY_SATISFIERS in scanners/capabilities.mjs.
@@ -29,7 +29,10 @@ import Ajv2020 from 'ajv/dist/2020.js';
29
29
  const REGISTRY_DIR = path.dirname(fileURLToPath(import.meta.url));
30
30
  const ADAPTERS_DIR = path.join(REGISTRY_DIR, 'adapters');
31
31
  const SCHEMAS_ROOT = path.join(REGISTRY_DIR, '..', 'schemas');
32
- const SUPPORTED_CONTRACT = 'sbf.adapter/1';
32
+ // D-adapter-verification-basis (W2/B6): bumped 1->2 for the new required `verificationBasis`
33
+ // field -- a real breaking change to the descriptor shape, same "bump the contract const" house
34
+ // style A7-A11 already established for sbf_contract.
35
+ const SUPPORTED_CONTRACT = 'sbf.adapter/2';
33
36
 
34
37
  let _ajv = null;
35
38
  function ajv() {
@@ -67,7 +70,7 @@ async function loadOneAdapter(file, schema) {
67
70
  }
68
71
  const descriptor = mod.adapter;
69
72
  if (!descriptor || typeof descriptor !== 'object') {
70
- return { error: { file, message: 'must `export const adapter = {...}` (sbf.adapter/1 shape) -- no such export found' } };
73
+ return { error: { file, message: `must \`export const adapter = {...}\` (${SUPPORTED_CONTRACT} shape) -- no such export found` } };
71
74
  }
72
75
  if (descriptor.contract !== SUPPORTED_CONTRACT) {
73
76
  return { error: { file, message: `declares contract "${descriptor.contract}" -- this build only understands "${SUPPORTED_CONTRACT}"` } };
@@ -6,3 +6,28 @@ export function lineNumberAt(text, index) {
6
6
  for (let i = 0; i < index; i++) if (text[i] === '\n') line++;
7
7
  return line;
8
8
  }
9
+
10
+ // D-cross-adapter-root-detection: same extraction pattern as lineNumberAt above, one adapter
11
+ // generation later -- python-fastapi.mjs and scanners/adapters/_express-shared.mjs (shared by
12
+ // typescript-express.mjs/javascript-express.mjs) each already had their own private copy of both
13
+ // functions before java-spring.mjs needed them too. `excludeGlobs` is a parameter, not baked in,
14
+ // because the two existing copies actually disagreed on it (Python's excludes .venv/site-packages/
15
+ // __pycache__; the Express one excludes dist/build) -- inlining either would have been wrong for
16
+ // the other caller.
17
+ import { execFileSync } from 'node:child_process';
18
+ import path from 'node:path';
19
+
20
+ export function listRgFiles(dir, globs, excludeGlobs = []) {
21
+ try {
22
+ const out = execFileSync('rg', ['--files', ...globs.flatMap((g) => ['-g', g]), ...excludeGlobs.flatMap((g) => ['-g', g]), dir], { encoding: 'utf8' });
23
+ return out.split('\n').filter(Boolean).sort(); // O6: rg --files order isn't guaranteed.
24
+ } catch {
25
+ return []; // rg exits 1 on "no files matched" -- not an error, just nothing to report
26
+ }
27
+ }
28
+
29
+ export function byShallowestThenName(a, b) {
30
+ const depthA = a.split(path.sep).length;
31
+ const depthB = b.split(path.sep).length;
32
+ return depthA !== depthB ? depthA - depthB : a.localeCompare(b);
33
+ }
@@ -5,9 +5,9 @@
5
5
  "description": "Validates the JSON-shaped fields of the `adapter` object a scanners/adapters/<id>.mjs module must export (detect/scan/diagnostics/listReadSet are functions and are checked separately -- JSON Schema has no vocabulary for them). Loaded by scanners/registry.mjs at process start. See D-adapter-registry in DECISIONS.md.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
- "required": ["contract", "id", "title", "specificity", "confidence", "capabilities"],
8
+ "required": ["contract", "id", "title", "specificity", "confidence", "capabilities", "verificationBasis"],
9
9
  "properties": {
10
- "contract": { "const": "sbf.adapter/1" },
10
+ "contract": { "const": "sbf.adapter/2" },
11
11
  "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
12
12
  "title": { "type": "string" },
13
13
  "specificity": { "type": "number" },
@@ -18,6 +18,10 @@
18
18
  "enum": ["api.operations", "api.request-shape", "resource.fetch", "codegen.handles"]
19
19
  },
20
20
  "additionalProperties": { "type": "boolean" }
21
+ },
22
+ "verificationBasis": {
23
+ "description": "D-adapter-verification-basis (W2/B6): a DIFFERENT axis from `confidence` above -- confidence means \"how sure was detect() this repo uses this framework,\" verificationBasis means \"how well was this adapter's own parsing/codegen logic checked against real code, ever.\" official-reference: verified against a framework-author-maintained reference stack. production-repo: verified against a real production repo. community-sample: verified against one hand-read community boilerplate, no official reference exists. synthetic-only: no real-world oracle at all, only a committed synthetic fixture. not-applicable: reconnaissance-only, no codegen to verify (generic-grep).",
24
+ "enum": ["official-reference", "production-repo", "community-sample", "synthetic-only", "not-applicable"]
21
25
  }
22
26
  }
23
27
  }
@@ -20,7 +20,12 @@
20
20
  "code": { "type": "string", "pattern": "^CONTRACT_[A-Z_]+$" },
21
21
  "subject": { "type": ["string", "null"] },
22
22
  "reason": { "type": "string" },
23
- "at": { "type": "string", "format": "date-time" }
23
+ "at": { "type": "string", "format": "date-time" },
24
+ "expires_at": {
25
+ "description": "D-waiver-expiry: optional, set only when `contract waive --expires <Nd>` was used. Once now >= expires_at, this waiver stops covering its warning -- see contracts/completeness.mjs's evaluateResolution().",
26
+ "type": "string",
27
+ "format": "date-time"
28
+ }
24
29
  }
25
30
  }
26
31
  }
@@ -7,7 +7,7 @@
7
7
  "additionalProperties": false,
8
8
  "required": ["sbf_contract", "feature_id", "feature_uid", "source", "operations", "warnings", "completeness"],
9
9
  "properties": {
10
- "sbf_contract": { "const": "6" },
10
+ "sbf_contract": { "const": "8" },
11
11
  "feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
12
12
  "feature_uid": { "type": "string", "format": "uuid" },
13
13
  "source": {
@@ -98,6 +98,15 @@
98
98
  }
99
99
  }
100
100
  }
101
+ },
102
+ "pathParamsHeuristic": {
103
+ "description": "A9: names every path-parameter segment in `pathParams` whose schema still came from this contract's own name heuristic (`/id$/i` -> a UUID pattern) rather than a real source document -- because no source document was given, the source declared no schema for that segment, or the schema failed to resolve. Present only when at least one segment is still heuristic-derived; a segment absent from this list was resolved from the source document's own real schema.",
104
+ "type": "array",
105
+ "items": { "type": "string" }
106
+ },
107
+ "sourceDescription": {
108
+ "description": "A10: the operation's `description`, copied verbatim from a real source document. Present only when `contract emit --descriptions` (opt-in, unlike every other source-backed field in this schema) was passed AND the source document declared one for this exact operation AND it did not exceed the length cap.",
109
+ "type": "string"
101
110
  }
102
111
  }
103
112
  }
@@ -22,6 +22,7 @@
22
22
  "idField": { "type": ["string", "null"] },
23
23
  "readPath": { "type": ["string", "null"] },
24
24
  "requiredAuthority": { "type": "string" },
25
+ "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)." },
25
26
  "willGenerateResolver": { "type": "boolean" }
26
27
  }
27
28
  }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env bash
2
+ # Generated by backend-skeleton (stack choice: postgres-dev-db). Self-contained in this repo --
3
+ # does not require backend-skeleton to be installed to run. See scripts/_bskel-lib.sh for
4
+ # shared helpers (the same env_upsert() the ngrok stack choice's script already uses).
5
+ #
6
+ # D-docker-postgres-stack: this is a LOCAL DEV database only, not a production deployment
7
+ # recipe -- no TLS, a fixed default password, a single unreplicated container. Named here as a
8
+ # deliberate, permanent scope limit, the same "narrow and honest beats broad and wrong" posture
9
+ # A12's own scoping already established for this backlog.
10
+ set -euo pipefail
11
+ cd "$(dirname "${BASH_SOURCE[0]}")/.."
12
+ source scripts/_bskel-lib.sh
13
+
14
+ COMPOSE_FILE="docker-compose.postgres.yml"
15
+ ENV_FILE=".env"
16
+ POSTGRES_USER="${POSTGRES_USER:-app}"
17
+ POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-app}"
18
+ POSTGRES_DB="${POSTGRES_DB:-app}"
19
+ POSTGRES_PORT="${POSTGRES_PORT:-5432}"
20
+ TIMEOUT_S=30
21
+
22
+ while [ $# -gt 0 ]; do
23
+ case "$1" in
24
+ --timeout) TIMEOUT_S="$2"; shift 2 ;;
25
+ *) echo "unknown argument: $1" >&2; exit 14 ;;
26
+ esac
27
+ done
28
+
29
+ if ! command -v docker >/dev/null 2>&1; then
30
+ echo "docker not found on PATH -- install Docker before running this script" >&2
31
+ exit 1
32
+ fi
33
+
34
+ export POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB POSTGRES_PORT
35
+ echo "starting postgres via $COMPOSE_FILE..."
36
+ docker compose -f "$COMPOSE_FILE" up -d
37
+
38
+ echo "waiting for postgres to accept connections (up to ${TIMEOUT_S}s)..."
39
+ waited=0
40
+ until docker compose -f "$COMPOSE_FILE" exec -T db pg_isready -U "$POSTGRES_USER" >/dev/null 2>&1; do
41
+ waited=$((waited + 1))
42
+ if [ "$waited" -ge "$TIMEOUT_S" ]; then
43
+ echo "postgres did not become ready within ${TIMEOUT_S}s -- check: docker compose -f $COMPOSE_FILE logs" >&2
44
+ exit 1
45
+ fi
46
+ sleep 1
47
+ done
48
+
49
+ DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DB}"
50
+ env_upsert "$ENV_FILE" DATABASE_URL "$DATABASE_URL"
51
+ echo "postgres is up, wrote DATABASE_URL to $ENV_FILE"
52
+ echo "note: if your app reads a differently-named variable, copy/rename this value (and pass that name to \`bskel scan --db --database-url-env <NAME>\` for schema introspection) -- see .env.example"
@@ -0,0 +1,18 @@
1
+ # Generated by backend-skeleton (stack choice: postgres-dev-db). A LOCAL-ONLY Postgres for
2
+ # development -- never started automatically by bskel itself; run it via scripts/db-up.sh.
3
+ # `${VAR:-default}` below is Docker Compose's own env substitution syntax (single braces), not
4
+ # this project's own double-brace template syntax -- scripts/db-up.sh exports these before
5
+ # calling `docker compose up`.
6
+ services:
7
+ db:
8
+ image: postgres:16-alpine
9
+ environment:
10
+ POSTGRES_USER: ${POSTGRES_USER:-app}
11
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app}
12
+ POSTGRES_DB: ${POSTGRES_DB:-app}
13
+ ports:
14
+ - "${POSTGRES_PORT:-5432}:5432"
15
+ volumes:
16
+ - bskel-postgres-dev-data:/var/lib/postgresql/data
17
+ volumes:
18
+ bskel-postgres-dev-data: