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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/bin/bskel.mjs +2384 -0
  4. package/contracts/completeness.mjs +176 -0
  5. package/contracts/emit.mjs +287 -0
  6. package/contracts/export.mjs +325 -0
  7. package/contracts/openapi.mjs +869 -0
  8. package/contracts/validate.mjs +147 -0
  9. package/handles/_engine.mjs +281 -0
  10. package/handles/codec.mjs +119 -0
  11. package/handles/conformance.mjs +74 -0
  12. package/handles/providers/java-spring/ast-bridge.mjs +59 -0
  13. package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
  14. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
  15. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
  16. package/handles/providers/java-spring/ast-helper/gradlew +248 -0
  17. package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
  18. package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
  19. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
  20. package/handles/providers/java-spring/emit.mjs +232 -0
  21. package/handles/providers/java-spring/patch-strategy.mjs +229 -0
  22. package/handles/providers/java-spring/plan.mjs +377 -0
  23. package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
  24. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
  25. package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
  26. package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
  27. package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
  28. package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
  29. package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
  30. package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
  31. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
  32. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
  33. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
  34. package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
  35. package/handles/providers/java-spring.mjs +21 -0
  36. package/handles/providers/python-fastapi/emit.mjs +171 -0
  37. package/handles/providers/python-fastapi/plan.mjs +186 -0
  38. package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
  39. package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
  40. package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
  41. package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
  42. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
  43. package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
  44. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
  45. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
  46. package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
  47. package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
  48. package/handles/providers/python-fastapi.mjs +22 -0
  49. package/handles/providers/typescript-express/emit.mjs +128 -0
  50. package/handles/providers/typescript-express/plan.mjs +234 -0
  51. package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
  52. package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
  53. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
  54. package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
  55. package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
  56. package/handles/providers/typescript-express.mjs +20 -0
  57. package/handles/registry.mjs +90 -0
  58. package/lib/cli.mjs +430 -0
  59. package/lib/doctor.mjs +200 -0
  60. package/lib/exit-codes.mjs +67 -0
  61. package/lib/featureid.mjs +55 -0
  62. package/lib/featurelifecycle.mjs +205 -0
  63. package/lib/fsutil.mjs +50 -0
  64. package/lib/gate-definitions.mjs +293 -0
  65. package/lib/gates.mjs +263 -0
  66. package/lib/handles-manifest.mjs +92 -0
  67. package/lib/lock.mjs +68 -0
  68. package/lib/patch-approvals.mjs +56 -0
  69. package/lib/paths.mjs +21 -0
  70. package/lib/repo.mjs +44 -0
  71. package/lib/schema-validate.mjs +56 -0
  72. package/lib/state.mjs +124 -0
  73. package/lib/template.mjs +35 -0
  74. package/lib/verify.mjs +206 -0
  75. package/lib/workflow.mjs +142 -0
  76. package/new/fastapi.mjs +165 -0
  77. package/new/index.mjs +62 -0
  78. package/new/params.mjs +233 -0
  79. package/new/spring.mjs +198 -0
  80. package/new/templates/fastapi/README.md +26 -0
  81. package/new/templates/fastapi/app/__init__.py +0 -0
  82. package/new/templates/fastapi/app/main.py +8 -0
  83. package/new/templates/fastapi/gitignore +6 -0
  84. package/new/templates/fastapi/pyproject.toml +14 -0
  85. package/package.json +50 -0
  86. package/scanners/adapters/_express-shared.mjs +238 -0
  87. package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
  88. package/scanners/adapters/generic-grep.mjs +128 -0
  89. package/scanners/adapters/java-spring.mjs +301 -0
  90. package/scanners/adapters/javascript-express.mjs +422 -0
  91. package/scanners/adapters/python-fastapi.mjs +348 -0
  92. package/scanners/adapters/typescript-express.mjs +299 -0
  93. package/scanners/capabilities.mjs +90 -0
  94. package/scanners/conformance.mjs +59 -0
  95. package/scanners/db/introspect.mjs +109 -0
  96. package/scanners/db/migrations.mjs +126 -0
  97. package/scanners/index.mjs +281 -0
  98. package/scanners/registry.mjs +130 -0
  99. package/scanners/render.mjs +136 -0
  100. package/scanners/text-util.mjs +8 -0
  101. package/schemas/adapter.schema.json +23 -0
  102. package/schemas/agent-envelope.schema.json +21 -0
  103. package/schemas/contract-resolution.schema.json +28 -0
  104. package/schemas/feature-contract.schema.json +78 -0
  105. package/schemas/feature-index.schema.json +25 -0
  106. package/schemas/feature.schema.json +17 -0
  107. package/schemas/gate-event.schema.json +19 -0
  108. package/schemas/handles-plan.schema.json +31 -0
  109. package/schemas/handles-provider.schema.json +26 -0
  110. package/schemas/patch-approvals.schema.json +28 -0
  111. package/schemas/scan-report.schema.json +102 -0
  112. package/schemas/stack-choice.schema.json +89 -0
  113. package/schemas/stack-record.schema.json +20 -0
  114. package/schemas/state.schema.json +43 -0
  115. package/scripts/preflight-base-ref.sh +226 -0
  116. package/stack/apply.mjs +159 -0
  117. package/stack/bootstrap/_lib.sh +73 -0
  118. package/stack/bootstrap/ngrok.sh +90 -0
  119. 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,8 @@
1
+ from fastapi import FastAPI
2
+
3
+ app = FastAPI(title="{{NAME}}")
4
+
5
+
6
+ @app.get("/health")
7
+ def health() -> dict[str, str]:
8
+ return {"status": "ok"}
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .env
5
+ specs/
6
+ .sbf/
@@ -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
+ }