backend-skeleton 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/bin/bskel.mjs +2384 -0
- package/contracts/completeness.mjs +176 -0
- package/contracts/emit.mjs +287 -0
- package/contracts/export.mjs +325 -0
- package/contracts/openapi.mjs +869 -0
- package/contracts/validate.mjs +147 -0
- package/handles/_engine.mjs +281 -0
- package/handles/codec.mjs +119 -0
- package/handles/conformance.mjs +74 -0
- package/handles/providers/java-spring/ast-bridge.mjs +59 -0
- package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/handles/providers/java-spring/ast-helper/gradlew +248 -0
- package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
- package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
- package/handles/providers/java-spring/emit.mjs +232 -0
- package/handles/providers/java-spring/patch-strategy.mjs +229 -0
- package/handles/providers/java-spring/plan.mjs +377 -0
- package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
- package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
- package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
- package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
- package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
- package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
- package/handles/providers/java-spring.mjs +21 -0
- package/handles/providers/python-fastapi/emit.mjs +171 -0
- package/handles/providers/python-fastapi/plan.mjs +186 -0
- package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
- package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
- package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
- package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
- package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
- package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
- package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
- package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
- package/handles/providers/python-fastapi.mjs +22 -0
- package/handles/providers/typescript-express/emit.mjs +128 -0
- package/handles/providers/typescript-express/plan.mjs +234 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
- package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
- package/handles/providers/typescript-express.mjs +20 -0
- package/handles/registry.mjs +90 -0
- package/lib/cli.mjs +430 -0
- package/lib/doctor.mjs +200 -0
- package/lib/exit-codes.mjs +67 -0
- package/lib/featureid.mjs +55 -0
- package/lib/featurelifecycle.mjs +205 -0
- package/lib/fsutil.mjs +50 -0
- package/lib/gate-definitions.mjs +293 -0
- package/lib/gates.mjs +263 -0
- package/lib/handles-manifest.mjs +92 -0
- package/lib/lock.mjs +68 -0
- package/lib/patch-approvals.mjs +56 -0
- package/lib/paths.mjs +21 -0
- package/lib/repo.mjs +44 -0
- package/lib/schema-validate.mjs +56 -0
- package/lib/state.mjs +124 -0
- package/lib/template.mjs +35 -0
- package/lib/verify.mjs +206 -0
- package/lib/workflow.mjs +142 -0
- package/new/fastapi.mjs +165 -0
- package/new/index.mjs +62 -0
- package/new/params.mjs +233 -0
- package/new/spring.mjs +198 -0
- package/new/templates/fastapi/README.md +26 -0
- package/new/templates/fastapi/app/__init__.py +0 -0
- package/new/templates/fastapi/app/main.py +8 -0
- package/new/templates/fastapi/gitignore +6 -0
- package/new/templates/fastapi/pyproject.toml +14 -0
- package/package.json +50 -0
- package/scanners/adapters/_express-shared.mjs +238 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
- package/scanners/adapters/generic-grep.mjs +128 -0
- package/scanners/adapters/java-spring.mjs +301 -0
- package/scanners/adapters/javascript-express.mjs +422 -0
- package/scanners/adapters/python-fastapi.mjs +348 -0
- package/scanners/adapters/typescript-express.mjs +299 -0
- package/scanners/capabilities.mjs +90 -0
- package/scanners/conformance.mjs +59 -0
- package/scanners/db/introspect.mjs +109 -0
- package/scanners/db/migrations.mjs +126 -0
- package/scanners/index.mjs +281 -0
- package/scanners/registry.mjs +130 -0
- package/scanners/render.mjs +136 -0
- package/scanners/text-util.mjs +8 -0
- package/schemas/adapter.schema.json +23 -0
- package/schemas/agent-envelope.schema.json +21 -0
- package/schemas/contract-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +78 -0
- package/schemas/feature-index.schema.json +25 -0
- package/schemas/feature.schema.json +17 -0
- package/schemas/gate-event.schema.json +19 -0
- package/schemas/handles-plan.schema.json +31 -0
- package/schemas/handles-provider.schema.json +26 -0
- package/schemas/patch-approvals.schema.json +28 -0
- package/schemas/scan-report.schema.json +102 -0
- package/schemas/stack-choice.schema.json +89 -0
- package/schemas/stack-record.schema.json +20 -0
- package/schemas/state.schema.json +43 -0
- package/scripts/preflight-base-ref.sh +226 -0
- package/stack/apply.mjs +159 -0
- package/stack/bootstrap/_lib.sh +73 -0
- package/stack/bootstrap/ngrok.sh +90 -0
- package/stack/catalog/ngrok.yml +63 -0
package/new/spring.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// P2 (D-greenfield-bootstrap): the only `bskel` command that talks to a network service --
|
|
2
|
+
// Spring Initializr's own public `start.spring.io` REST API (the same one behind `spring init`/
|
|
3
|
+
// the start.spring.io web UI). Every other command in this tool is pure local git/fs, so this is
|
|
4
|
+
// a genuinely new risk category: never auto-triggered by any other command, always a single
|
|
5
|
+
// explicit invocation, and `--offline` refuses cleanly instead of hanging on a dead connection.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
// "Pinned" means the DEFAULT DEPENDENCY SET is a named, reviewable constant here, not a live query
|
|
12
|
+
// against whatever Initializr's own current defaults happen to be today -- the same dependency
|
|
13
|
+
// set test/fixtures/java-compile/build.gradle and the real oracle repo both already use
|
|
14
|
+
// (web, data-jpa, security, validation, lombok), so a freshly scaffolded project is immediately
|
|
15
|
+
// compatible with every other `bskel` command (handles codegen, contract emit's Bean Validation
|
|
16
|
+
// assumptions, etc.) with zero extra setup. `javaVersion` matches this whole tool's established
|
|
17
|
+
// Java 17 baseline (CLAUDE.md's own documented oracle-repo toolchain). Deliberately does NOT pin
|
|
18
|
+
// an exact `bootVersion` -- Spring Initializr only serves actively-supported versions and ages
|
|
19
|
+
// old ones out on its own schedule, so a hardcoded exact version is a maintenance liability here
|
|
20
|
+
// (unlike a gate token, where rigidity is the safety property); this tool's own Jackson-package
|
|
21
|
+
// detection (handles/providers/java-spring/emit.mjs's detectJacksonPackage) already adapts to
|
|
22
|
+
// whichever major version Initializr hands back, Boot 3 or 4.
|
|
23
|
+
//
|
|
24
|
+
// P2b (D-greenfield-parameters): these are now DEFAULTS a caller can override, not hardcodes. The
|
|
25
|
+
// pinning argument above survives intact -- what it was ever protecting is "bskel does not silently
|
|
26
|
+
// track Initializr's moving defaults", not "a user may not state their own group id".
|
|
27
|
+
export const BASE_DEPENDENCIES = Object.freeze(['web', 'data-jpa', 'security', 'validation', 'lombok']);
|
|
28
|
+
export const DEFAULT_JAVA_VERSION = '17';
|
|
29
|
+
export const DEFAULT_GROUP_ID = 'com.example';
|
|
30
|
+
|
|
31
|
+
const INITIALIZR_URL = 'https://start.spring.io/starter.zip';
|
|
32
|
+
|
|
33
|
+
// P2b: the three baseline dependencies OTHER `bskel` commands actually require downstream. Dropping
|
|
34
|
+
// `security` or `lombok` degrades nothing bskel itself does, so neither is warned about.
|
|
35
|
+
const REQUIRED_FOR_BSKEL = Object.freeze({
|
|
36
|
+
web: 'without `web` the project has no @RestController/@RequestMapping endpoints at all, so `bskel scan`\'s java-spring adapter finds no controllers and `bskel contract emit` has nothing to build operations from',
|
|
37
|
+
'data-jpa': 'without `data-jpa` the project has no @Entity classes, so the java-spring adapter reports zero resources and `bskel handles plan` approves nothing (resource.fetch has nothing to fetch)',
|
|
38
|
+
validation: 'without `validation` the resolver `bskel handles emit` generates will not compile -- its patchField() imports jakarta.validation.Validator / ConstraintViolation (see D-patch-strategy)',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function splitDependencyList(raw, flag) {
|
|
42
|
+
const ids = String(raw).split(',').map((s) => s.trim()).filter((s) => s !== '');
|
|
43
|
+
if (ids.length === 0) {
|
|
44
|
+
throw new Error(`--${flag} was given no usable dependency ids (got ${JSON.stringify(raw)}) -- pass a comma-separated list of start.spring.io dependency ids, e.g. --${flag} actuator,postgresql`);
|
|
45
|
+
}
|
|
46
|
+
return [...new Set(ids)];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// P2b (D-greenfield-parameters), user decision: `--dependencies` REPLACES the baseline five rather
|
|
50
|
+
// than adding to them. That is deliberately the more dangerous of the two semantics -- so the danger
|
|
51
|
+
// is made VISIBLE (a specific, named warning per missing baseline dependency, naming what breaks)
|
|
52
|
+
// rather than prevented. `--add-dependencies` is the additive-only flag for the common case; it
|
|
53
|
+
// cannot drop anything, so it never warns. Mutually exclusive -- merging their semantics would make
|
|
54
|
+
// "did I replace or extend?" un-answerable from the command line alone.
|
|
55
|
+
//
|
|
56
|
+
// Pure: returns the resolved set plus the warnings, so the caller decides where they go (stderr,
|
|
57
|
+
// per this CLI's own contract that warnings are never suppressed by --quiet or --json).
|
|
58
|
+
export function resolveSpringDependencies({ dependencies = null, addDependencies = null } = {}) {
|
|
59
|
+
if (dependencies != null && addDependencies != null) {
|
|
60
|
+
throw new Error('--dependencies and --add-dependencies are mutually exclusive -- --dependencies REPLACES the baseline set (web, data-jpa, security, validation, lombok), --add-dependencies extends it');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let resolved;
|
|
64
|
+
if (dependencies != null) {
|
|
65
|
+
resolved = splitDependencyList(dependencies, 'dependencies');
|
|
66
|
+
} else if (addDependencies != null) {
|
|
67
|
+
resolved = [...new Set([...BASE_DEPENDENCIES, ...splitDependencyList(addDependencies, 'add-dependencies')])];
|
|
68
|
+
} else {
|
|
69
|
+
resolved = [...BASE_DEPENDENCIES];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const warnings = [];
|
|
73
|
+
for (const [id, consequence] of Object.entries(REQUIRED_FOR_BSKEL)) {
|
|
74
|
+
if (resolved.includes(id)) continue;
|
|
75
|
+
warnings.push(`warning: the requested dependency set does not include \`${id}\` -- ${consequence}. Scaffolding anyway (you asked for this set explicitly); add it with \`--add-dependencies ${id}\` if that was not intended.`);
|
|
76
|
+
}
|
|
77
|
+
return { dependencies: resolved, warnings };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Java package names can't contain hyphens -- `demo-app` becomes `demoapp`, matching the
|
|
81
|
+
// convention `handles/providers/java-spring/plan.mjs::detectBasePackage()` itself would derive
|
|
82
|
+
// from the resulting *Application.java's own package declaration.
|
|
83
|
+
//
|
|
84
|
+
// P2b: every parameter below is optional and defaults to exactly what P2 hardcoded, so calling this
|
|
85
|
+
// with only `{ slug }` produces a BYTE-IDENTICAL url to the pre-P2b one -- including query-parameter
|
|
86
|
+
// ORDER, which is why the four optional keys are appended after the original eight rather than
|
|
87
|
+
// interleaved. `test/new-cli.test.mjs` pins that string exactly.
|
|
88
|
+
export function buildInitializrUrl({
|
|
89
|
+
slug,
|
|
90
|
+
groupId = DEFAULT_GROUP_ID,
|
|
91
|
+
artifactId = null,
|
|
92
|
+
packageName = null,
|
|
93
|
+
name = null,
|
|
94
|
+
description = null,
|
|
95
|
+
projectVersion = null,
|
|
96
|
+
javaVersion = DEFAULT_JAVA_VERSION,
|
|
97
|
+
packaging = null,
|
|
98
|
+
dependencies = BASE_DEPENDENCIES,
|
|
99
|
+
} = {}) {
|
|
100
|
+
const packageSuffix = slug.replace(/-/g, '');
|
|
101
|
+
const params = new URLSearchParams({
|
|
102
|
+
type: 'gradle-project',
|
|
103
|
+
language: 'java',
|
|
104
|
+
javaVersion,
|
|
105
|
+
groupId,
|
|
106
|
+
artifactId: artifactId ?? slug,
|
|
107
|
+
name: name ?? slug,
|
|
108
|
+
packageName: packageName ?? `${groupId}.${packageSuffix}`,
|
|
109
|
+
dependencies: [...dependencies].join(','),
|
|
110
|
+
});
|
|
111
|
+
// Never sent unless explicitly asked for: an omitted parameter lets Initializr apply its own
|
|
112
|
+
// current default, which is the same reason `bootVersion` is never sent at all.
|
|
113
|
+
if (packaging != null) params.set('packaging', packaging);
|
|
114
|
+
if (description != null) params.set('description', description);
|
|
115
|
+
if (projectVersion != null) params.set('version', projectVersion);
|
|
116
|
+
return `${INITIALIZR_URL}?${params.toString()}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Initializr answers a bad `dependencies`/`type`/`packaging`/`language` with a clean, quotable JSON
|
|
120
|
+
// body: {"timestamp":...,"status":400,"error":"Bad Request","message":"Unknown dependency
|
|
121
|
+
// 'not-a-real-dep' check project metadata","path":"/starter.zip"} (measured 2026-08-23). Surfacing
|
|
122
|
+
// that `message` verbatim is what makes pass-through validation an honest choice for those four
|
|
123
|
+
// parameters instead of a shrug. Defensive on every step: a mocked/odd response object without
|
|
124
|
+
// .json() must not turn a clean HTTP error into a TypeError.
|
|
125
|
+
async function describeHttpFailure(response) {
|
|
126
|
+
if (typeof response.json !== 'function') return null;
|
|
127
|
+
try {
|
|
128
|
+
const body = await response.json();
|
|
129
|
+
const message = body?.message;
|
|
130
|
+
return typeof message === 'string' && message !== '' ? message : null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// `--offline` (or no network at all) must fail with a clear, actionable message -- never hang or
|
|
137
|
+
// produce a raw fetch stack trace. Mirrors `bskel preflight --offline`'s own precedent for the
|
|
138
|
+
// no-network case.
|
|
139
|
+
export async function scaffoldSpring({
|
|
140
|
+
dir,
|
|
141
|
+
slug,
|
|
142
|
+
offline = false,
|
|
143
|
+
groupId = DEFAULT_GROUP_ID,
|
|
144
|
+
artifactId = null,
|
|
145
|
+
packageName = null,
|
|
146
|
+
name = null,
|
|
147
|
+
description = null,
|
|
148
|
+
projectVersion = null,
|
|
149
|
+
javaVersion = DEFAULT_JAVA_VERSION,
|
|
150
|
+
packaging = null,
|
|
151
|
+
dependencies = BASE_DEPENDENCIES,
|
|
152
|
+
}) {
|
|
153
|
+
if (offline) {
|
|
154
|
+
throw new Error('bskel new --stack spring requires network access (calls start.spring.io) -- re-run without --offline, or use --stack fastapi (fully local, no network call)');
|
|
155
|
+
}
|
|
156
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length > 0) {
|
|
157
|
+
throw new Error(`${dir} already exists and is not empty -- refusing to scaffold into it`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const url = buildInitializrUrl({ slug, groupId, artifactId, packageName, name, description, projectVersion, javaVersion, packaging, dependencies });
|
|
161
|
+
let response;
|
|
162
|
+
try {
|
|
163
|
+
response = await fetch(url);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
throw new Error(`could not reach start.spring.io (${err.message}) -- check network access, or use --stack fastapi (fully local, no network call)`);
|
|
166
|
+
}
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
const detail = await describeHttpFailure(response);
|
|
169
|
+
throw new Error(`start.spring.io returned ${response.status} ${response.statusText}${detail ? ` -- it said: ${detail}` : ''} -- the request was: ${url}`);
|
|
170
|
+
}
|
|
171
|
+
const zipBytes = Buffer.from(await response.arrayBuffer());
|
|
172
|
+
|
|
173
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
174
|
+
const zipPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'bskel-new-spring-')), `${slug}.zip`);
|
|
175
|
+
fs.writeFileSync(zipPath, zipBytes);
|
|
176
|
+
try {
|
|
177
|
+
// P2b: `baseDir` is deliberately NEVER sent to Initializr and is deliberately NOT exposed as
|
|
178
|
+
// a flag. With no baseDir the archive is FLAT (build.gradle/settings.gradle/src/gradlew at the
|
|
179
|
+
// root), which is exactly what this extraction assumes -- setting it would nest the whole
|
|
180
|
+
// project one level down and silently break every downstream adapter's detect(), which look
|
|
181
|
+
// for `build.gradle` + `src/main/java` at the repo root. Pinned by a regression test.
|
|
182
|
+
execFileSync('unzip', ['-q', zipPath, '-d', dir]);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
throw new Error(`could not extract the downloaded project (is \`unzip\` on PATH?): ${err.message}`);
|
|
185
|
+
} finally {
|
|
186
|
+
fs.rmSync(path.dirname(zipPath), { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
dir,
|
|
191
|
+
dependencies: [...dependencies],
|
|
192
|
+
javaVersion,
|
|
193
|
+
groupId,
|
|
194
|
+
artifactId: artifactId ?? slug,
|
|
195
|
+
packageName: packageName ?? `${groupId}.${slug.replace(/-/g, '')}`,
|
|
196
|
+
packaging,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# {{NAME}}
|
|
2
|
+
|
|
3
|
+
{{DESCRIPTION_BLOCK}}Scaffolded by `bskel new --stack fastapi`.
|
|
4
|
+
|
|
5
|
+
## Run it
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
9
|
+
pip install -e .
|
|
10
|
+
fastapi dev app/main.py --port {{PORT}} # or: uvicorn app.main:app --reload --port {{PORT}}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then check http://127.0.0.1:{{PORT}}/health
|
|
14
|
+
|
|
15
|
+
{{DATABASE_SECTION}}## Next steps
|
|
16
|
+
|
|
17
|
+
This is a local-only git repository with one commit. `bskel preflight` needs a real `origin`
|
|
18
|
+
remote with a resolvable default branch, so:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
gh repo create <name> --private --source=. --push # or push to a remote you already own
|
|
22
|
+
git remote set-head origin --auto
|
|
23
|
+
bskel preflight
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
From there, `bskel status` / `bskel next` will tell you what to run.
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "{{NAME}}"
|
|
3
|
+
version = "{{PROJECT_VERSION}}"
|
|
4
|
+
description = "{{DESCRIPTION}}"
|
|
5
|
+
{{LICENSE_LINE}}requires-python = "{{REQUIRES_PYTHON}}"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"fastapi[standard]>=0.115.0,<1.0.0",
|
|
8
|
+
"sqlmodel>=0.0.22",
|
|
9
|
+
"uvicorn[standard]>=0.30.0",
|
|
10
|
+
{{DATABASE_DEPENDENCY_LINES}}]
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["setuptools>=68"]
|
|
14
|
+
build-backend = "setuptools.build_meta"
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "backend-skeleton",
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Spec-driven backend scaffolding: brownfield-scan gate, feature_id-keyed contracts, UUID bidirectional handles, stack-choice wiring.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/popixoxipop-collab/backend-skeleton.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/popixoxipop-collab/backend-skeleton#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/popixoxipop-collab/backend-skeleton/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"bskel": "bin/bskel.mjs"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"bin/",
|
|
23
|
+
"lib/",
|
|
24
|
+
"contracts/",
|
|
25
|
+
"scanners/",
|
|
26
|
+
"handles/",
|
|
27
|
+
"new/",
|
|
28
|
+
"stack/",
|
|
29
|
+
"schemas/",
|
|
30
|
+
"scripts/preflight-base-ref.sh"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node --test test/*.test.mjs",
|
|
34
|
+
"test:pack": "node test/package-install.test.mjs",
|
|
35
|
+
"test:java-compile": "node scripts/java-compile-smoke.mjs",
|
|
36
|
+
"test:python-import": "node scripts/python-import-smoke.mjs",
|
|
37
|
+
"test:db-introspect": "node scripts/db-introspect-smoke.mjs",
|
|
38
|
+
"test:java-integration": "node scripts/java-integration-smoke.mjs",
|
|
39
|
+
"test:java-ast": "node scripts/java-ast-smoke.mjs",
|
|
40
|
+
"test:python-integration": "node scripts/python-integration-smoke.mjs",
|
|
41
|
+
"test:typescript-compile": "node scripts/typescript-typecheck-smoke.mjs",
|
|
42
|
+
"test:spring-initializr-canary": "node scripts/spring-initializr-canary.mjs"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"ajv": "^8.20.0",
|
|
46
|
+
"ajv-formats": "^3.0.1",
|
|
47
|
+
"yaml": "^2.9.0",
|
|
48
|
+
"pg": "^8.23.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// G6 (D-javascript-express-adapter): the primitives `typescript-express.mjs` (G5) and
|
|
2
|
+
// `javascript-express.mjs` (G6) genuinely share, extracted verbatim from the former when the
|
|
3
|
+
// latter was written -- same `_`-prefixed shared-helper convention `_java-spring-analyzer.mjs`
|
|
4
|
+
// already uses (scanners/registry.mjs skips `_`-prefixed files, so this file is never mistaken for
|
|
5
|
+
// an adapter), and the same "three adapters had privately duplicated it" reasoning that produced
|
|
6
|
+
// `scanners/text-util.mjs` under D-scanner-evidence.
|
|
7
|
+
//
|
|
8
|
+
// Deliberately NARROW: only the pieces that are byte-identical between the two adapters live here.
|
|
9
|
+
// Endpoint extraction, mount-edge building and prefix resolution are NOT shared -- they diverge
|
|
10
|
+
// materially (the TS adapter keys on a hardcoded `router` identifier and one node per FILE; the JS
|
|
11
|
+
// adapter binds the real declared variable name and needs one node per (file, variable) pair, see
|
|
12
|
+
// D-javascript-express-adapter). Parameterizing them into one function would have produced a worse
|
|
13
|
+
// abstraction than two clear implementations, the same "narrow, not general" call this codebase
|
|
14
|
+
// makes at every other cross-file resolution.
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
export const EXCLUDE_GLOBS = ['!**/node_modules/**', '!**/dist/**', '!**/build/**'];
|
|
20
|
+
export const VERBS = ['get', 'post', 'put', 'patch', 'delete'];
|
|
21
|
+
|
|
22
|
+
// A string literal in any of the three forms real Express route registration actually uses --
|
|
23
|
+
// found live in G5's own oracle: `router.use(\`/v1\`, v1)` uses a backtick TEMPLATE literal for a
|
|
24
|
+
// plain path with no interpolation, not a regular string.
|
|
25
|
+
export const STRING_LITERAL_RE = /^\s*["'`]([^"'`]*)["'`]/;
|
|
26
|
+
|
|
27
|
+
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
|
+
}
|
|
35
|
+
|
|
36
|
+
// `rg -l -e <pattern> -g <glob>...` -- the "which source files even mention this" pass both
|
|
37
|
+
// adapters' detect() runs before doing any real reading.
|
|
38
|
+
export function rgFilesMatching(pattern, globs, dir) {
|
|
39
|
+
try {
|
|
40
|
+
return execFileSync('rg', [
|
|
41
|
+
'-l', '-e', pattern,
|
|
42
|
+
...globs.flatMap((g) => ['-g', g]), ...EXCLUDE_GLOBS.flatMap((g) => ['-g', g]),
|
|
43
|
+
dir,
|
|
44
|
+
], { encoding: 'utf8' }).split('\n').filter(Boolean);
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
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
|
+
export function listCandidatePackageFiles(repoRoot) {
|
|
57
|
+
return listRgFiles(repoRoot, ['package.json']).sort(byShallowestThenName);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Real JSON.parse, not a bounded regex -- package.json is always valid JSON, so unlike
|
|
61
|
+
// java-spring's build.gradle or python-fastapi's pyproject.toml (both need a "good-enough regex,
|
|
62
|
+
// not a real parser" compromise), there is no regex-vs-parser trade-off to make here at all.
|
|
63
|
+
export function readPackageJson(packageJsonPath) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function declaresExpress(packageJsonPath) {
|
|
72
|
+
const pkg = readPackageJson(packageJsonPath);
|
|
73
|
+
return Boolean(pkg?.dependencies?.express || pkg?.devDependencies?.express);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// G6: the JavaScript/TypeScript counterpart of `_java-spring-analyzer.mjs`'s `maskNonCode()`
|
|
77
|
+
// (A2 Phase 1, D-java-analyzer), added for the same reason and against the same failure: a regex
|
|
78
|
+
// scanning raw source cannot tell code from prose about code. Found live, not anticipated -- the
|
|
79
|
+
// javascript-express fixture's own header comment contains the words `import { Router } from
|
|
80
|
+
// 'express'` (describing what the TS adapter looks for), and an unmasked
|
|
81
|
+
// `import\s+([^;]*?)\s*from\s*['"]express['"]` happily matched starting at the word "import"
|
|
82
|
+
// INSIDE that comment and ran across the newline into the real statement below it, yielding a
|
|
83
|
+
// nonsense import clause and silently collapsing the entire mount graph to empty prefixes.
|
|
84
|
+
// The same exposure lets a commented-out `// router.get('/old', oldHandler)` be reported as a
|
|
85
|
+
// live route by ANY of these regex adapters.
|
|
86
|
+
//
|
|
87
|
+
// Comments (line and block) are blanked to spaces ENTIRELY, markers included -- they must never
|
|
88
|
+
// look structurally like anything. String and template literals are left FULLY INTACT, unlike the
|
|
89
|
+
// Java masker which blanks string interiors: every path this adapter reports is read straight out
|
|
90
|
+
// of a string literal (`router.get('/:id', ...)`), so blanking interiors would destroy the values
|
|
91
|
+
// rather than protect them. Newlines are preserved and no character index shifts, so
|
|
92
|
+
// `lineNumberAt()` and every `matchBalancedParens()` offset stay valid against the masked text.
|
|
93
|
+
//
|
|
94
|
+
// Regex literals ARE tracked, and not defensively: `const re = /'/g;` contains an odd number of
|
|
95
|
+
// quote characters, and without regex tracking the scanner enters a phantom string that runs to
|
|
96
|
+
// the next quote ANYWHERE later in the file -- leaving every comment in between unmasked, which is
|
|
97
|
+
// precisely the phantom-route bug this function exists to prevent. Confirmed live before the
|
|
98
|
+
// tracking was added. Whether `/` opens a regex or is division is decided from the previous
|
|
99
|
+
// significant character (the standard JavaScript-lexer heuristic), and an unterminated literal
|
|
100
|
+
// bails at end of line so a misjudged division can never run away past it.
|
|
101
|
+
// Deliberately narrow. Arithmetic operators (`+ - * % ^ < > ~`) are legal regex-preceders in the
|
|
102
|
+
// grammar but never appear before one in real code (`a + /re/` is a type error), while
|
|
103
|
+
// `y++ / 2` IS real -- so including them buys nothing and costs a false positive. These twelve
|
|
104
|
+
// cover every position a regex literal actually occupies in practice.
|
|
105
|
+
const REGEX_PRECEDING_CHARS = new Set(['(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';']);
|
|
106
|
+
const REGEX_PRECEDING_KEYWORD_RE = /\b(?:return|typeof|case|in|of|new|delete|do|else|yield|await|void|instanceof)\s*$/;
|
|
107
|
+
|
|
108
|
+
function isRegexStart(lastSignificant, recentText) {
|
|
109
|
+
if (lastSignificant === null) return true; // start of file
|
|
110
|
+
if (REGEX_PRECEDING_CHARS.has(lastSignificant)) return true;
|
|
111
|
+
return REGEX_PRECEDING_KEYWORD_RE.test(recentText);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Returns the index just past the literal's closing `/`. Handles `\` escapes and `[...]` character
|
|
115
|
+
// classes (a `/` inside a class is not a terminator). Bails at a newline: a real regex literal
|
|
116
|
+
// cannot span lines, so hitting one means this `/` was division after all, and stopping there
|
|
117
|
+
// bounds the damage of a misjudgement to the rest of one line.
|
|
118
|
+
function skipRegexLiteral(text, start) {
|
|
119
|
+
let i = start + 1;
|
|
120
|
+
let inClass = false;
|
|
121
|
+
while (i < text.length) {
|
|
122
|
+
const ch = text[i];
|
|
123
|
+
if (ch === '\\') { i += 2; continue; }
|
|
124
|
+
if (ch === '\n') return i;
|
|
125
|
+
if (inClass) {
|
|
126
|
+
if (ch === ']') inClass = false;
|
|
127
|
+
i++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (ch === '[') { inClass = true; i++; continue; }
|
|
131
|
+
if (ch === '/') return i + 1;
|
|
132
|
+
i++;
|
|
133
|
+
}
|
|
134
|
+
return i;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function maskJsComments(text) {
|
|
138
|
+
const out = text.split('');
|
|
139
|
+
let i = 0;
|
|
140
|
+
let quote = null; // "'" | '"' | '`' when inside a string/template literal
|
|
141
|
+
let lastSignificant = null; // last non-whitespace CODE character seen
|
|
142
|
+
while (i < text.length) {
|
|
143
|
+
const ch = text[i];
|
|
144
|
+
if (quote) {
|
|
145
|
+
if (ch === '\\') { i += 2; continue; }
|
|
146
|
+
if (ch === quote) { quote = null; lastSignificant = ch; }
|
|
147
|
+
i++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (ch === '\'' || ch === '"' || ch === '`') { quote = ch; i++; continue; }
|
|
151
|
+
// Comment markers win over regex detection unconditionally, and correctly: `//` is never a
|
|
152
|
+
// valid empty regex, and a regex body cannot begin with `*`.
|
|
153
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
154
|
+
while (i < text.length && text[i] !== '\n') { out[i] = ' '; i++; }
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
158
|
+
const end = text.indexOf('*/', i + 2);
|
|
159
|
+
const stop = end === -1 ? text.length : end + 2;
|
|
160
|
+
for (; i < stop; i++) if (text[i] !== '\n') out[i] = ' ';
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (ch === '/' && isRegexStart(lastSignificant, text.slice(Math.max(0, i - 12), i))) {
|
|
164
|
+
i = skipRegexLiteral(text, i);
|
|
165
|
+
lastSignificant = '/';
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!/\s/.test(ch)) lastSignificant = ch;
|
|
169
|
+
i++;
|
|
170
|
+
}
|
|
171
|
+
return out.join('');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Walks forward from `openIndex` (text[openIndex] must be '(') tracking paren depth -- needed
|
|
175
|
+
// because a middleware array routinely nests its own parens/brackets, confirmed in G5's real
|
|
176
|
+
// oracle: `router.get('/:id([0-9]+)', [checkJwt, checkRole(['ADMINISTRATOR'], true)], show)`.
|
|
177
|
+
// Same technique python-fastapi.mjs's own matchBalancedParens already uses.
|
|
178
|
+
export function matchBalancedParens(text, openIndex) {
|
|
179
|
+
let depth = 0;
|
|
180
|
+
for (let i = openIndex; i < text.length; i++) {
|
|
181
|
+
if (text[i] === '(') depth++;
|
|
182
|
+
else if (text[i] === ')') {
|
|
183
|
+
depth--;
|
|
184
|
+
if (depth === 0) return i;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return -1;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Splits a balanced top-level argument list on commas, respecting nested (), [], {} -- needed to
|
|
191
|
+
// pull the LAST positional argument (the handler) out of `path, [middlewares], handler` without a
|
|
192
|
+
// naive split(',') breaking on the commas inside `[checkJwt, checkRole(...)]`.
|
|
193
|
+
export function splitTopLevelArgs(argsText) {
|
|
194
|
+
const parts = [];
|
|
195
|
+
let depth = 0;
|
|
196
|
+
let start = 0;
|
|
197
|
+
for (let i = 0; i < argsText.length; i++) {
|
|
198
|
+
const ch = argsText[i];
|
|
199
|
+
if ('([{'.includes(ch)) depth++;
|
|
200
|
+
else if (')]}'.includes(ch)) depth--;
|
|
201
|
+
else if (ch === ',' && depth === 0) {
|
|
202
|
+
parts.push(argsText.slice(start, i));
|
|
203
|
+
start = i + 1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const last = argsText.slice(start);
|
|
207
|
+
if (last.trim() !== '') parts.push(last);
|
|
208
|
+
return parts.map((p) => p.trim());
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function joinPath(base, segment) {
|
|
212
|
+
const b = (base || '').replace(/\/$/, '');
|
|
213
|
+
const s = (segment || '').replace(/^\//, '');
|
|
214
|
+
return s ? `${b}/${s}` : (b || '/');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Shared `diagnostics()` body for both Express adapters: whether any package.json was found at
|
|
218
|
+
// all, whether any declares express, and whether `rg` (which both adapters shell out to, and which
|
|
219
|
+
// they THROW on rather than degrade without) is actually on PATH.
|
|
220
|
+
export function expressDiagnostics(repoRoot) {
|
|
221
|
+
const messages = [];
|
|
222
|
+
const pkgFiles = listCandidatePackageFiles(repoRoot);
|
|
223
|
+
if (pkgFiles.length === 0) {
|
|
224
|
+
messages.push({ level: 'info', code: 'no-package-json', message: 'no package.json found' });
|
|
225
|
+
} else if (!pkgFiles.some((f) => declaresExpress(f))) {
|
|
226
|
+
messages.push({ level: 'info', code: 'express-not-a-dependency', message: `found ${pkgFiles.length} package.json file(s), but none declare an express dependency` });
|
|
227
|
+
}
|
|
228
|
+
let rgOk = true;
|
|
229
|
+
try {
|
|
230
|
+
execFileSync('rg', ['--version'], { stdio: 'pipe' });
|
|
231
|
+
} catch {
|
|
232
|
+
rgOk = false;
|
|
233
|
+
}
|
|
234
|
+
if (!rgOk) {
|
|
235
|
+
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
|
+
}
|
|
237
|
+
return messages;
|
|
238
|
+
}
|