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.
- package/README.md +12 -2
- package/bin/bskel.mjs +260 -11
- package/contracts/completeness.mjs +27 -2
- package/contracts/emit.mjs +68 -9
- package/contracts/export.mjs +94 -25
- package/contracts/openapi.mjs +307 -40
- package/handles/audit.mjs +83 -0
- package/handles/codec.mjs +11 -5
- package/handles/providers/java-spring/emit.mjs +9 -2
- package/handles/providers/java-spring/plan.mjs +20 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +13 -5
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +54 -20
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +17 -1
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +5 -0
- package/handles/providers/java-spring.mjs +2 -2
- package/handles/providers/python-fastapi/emit.mjs +5 -1
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +9 -1
- package/handles/providers/python-fastapi/templates/router.py.tmpl +43 -19
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +10 -1
- package/lib/cli.mjs +51 -3
- package/lib/handles-manifest.mjs +10 -4
- package/lib/repo.mjs +56 -0
- package/package.json +1 -1
- package/scanners/adapters/_express-shared.mjs +17 -12
- package/scanners/adapters/generic-grep.mjs +5 -1
- package/scanners/adapters/java-spring.mjs +60 -14
- package/scanners/adapters/javascript-express.mjs +5 -1
- package/scanners/adapters/python-fastapi.mjs +17 -14
- package/scanners/adapters/typescript-express.mjs +6 -1
- package/scanners/registry.mjs +5 -2
- package/scanners/text-util.mjs +25 -0
- package/schemas/adapter.schema.json +6 -2
- package/schemas/contract-resolution.schema.json +6 -1
- package/schemas/feature-contract.schema.json +10 -1
- package/schemas/handles-plan.schema.json +1 -0
- package/stack/bootstrap/db-up.sh +52 -0
- package/stack/bootstrap/docker-compose.postgres.yml +18 -0
- package/stack/catalog/postgres-dev-db.yml +55 -0
package/lib/handles-manifest.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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/
|
|
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 =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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/
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 =
|
|
286
|
-
if (!
|
|
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/
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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.
|
package/scanners/registry.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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}"` } };
|
package/scanners/text-util.mjs
CHANGED
|
@@ -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/
|
|
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": "
|
|
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:
|