backend-skeleton 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -4
- package/bin/bskel.mjs +125 -18
- package/contracts/export.mjs +39 -4
- package/contracts/openapi.mjs +292 -27
- package/contracts/validate.mjs +23 -4
- package/handles/_engine.mjs +75 -32
- package/handles/capability-codec.mjs +94 -0
- package/handles/codec.mjs +13 -3
- package/handles/providers/java-spring/emit.mjs +78 -33
- package/handles/providers/java-spring/observe.mjs +4 -3
- package/handles/providers/java-spring/plan.mjs +51 -7
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +13 -7
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +24 -3
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
- package/handles/providers/java-spring.mjs +8 -0
- package/handles/providers/python-fastapi/emit.mjs +21 -26
- package/handles/providers/python-fastapi/observe.mjs +6 -5
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +32 -4
- package/handles/providers/python-fastapi.mjs +3 -3
- package/handles/providers/typescript-express/emit.mjs +135 -46
- package/handles/providers/typescript-express/observe.mjs +7 -6
- package/handles/providers/typescript-express/plan.mjs +8 -1
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
- package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
- package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
- package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
- package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
- package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
- package/handles/providers/typescript-express.mjs +7 -4
- package/lib/cli.mjs +11 -2
- package/lib/exit-codes.mjs +21 -0
- package/lib/verify.mjs +23 -6
- package/package.json +5 -2
- package/scanners/adapters/_java-spring-analyzer.mjs +25 -2
- package/scanners/adapters/java-spring.mjs +108 -10
- package/scanners/adapters/javascript-express.mjs +46 -13
- package/scanners/adapters/python-fastapi.mjs +63 -18
- package/scanners/adapters/typescript-express.mjs +33 -7
- package/schemas/feature-contract.schema.json +3 -3
- package/schemas/handles-plan.schema.json +2 -0
- package/schemas/oracle-manifest.schema.json +58 -0
- package/schemas/stack-record.schema.json +6 -1
- package/stack/apply.mjs +47 -6
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
// Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
|
|
2
2
|
//
|
|
3
3
|
// In-process registry mapping a handle `type` string to its resolver instance -- deliberately NOT
|
|
4
|
-
// a database table
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// DECISIONS.md.
|
|
4
|
+
// a database table (that's HandleRegistry in handleEntities.ts, an unrelated concept that only
|
|
5
|
+
// shares the word "registry" -- see that file's own header). See D-typescript-express-provider /
|
|
6
|
+
// D-typescript-express-registry-parity in DECISIONS.md.
|
|
8
7
|
|
|
9
8
|
// Unlike the Python provider's ResourceResolver (which threads a per-request SQLAlchemy `session`
|
|
10
|
-
// through every
|
|
11
|
-
//
|
|
12
|
-
// instantiated once at startup (`export const
|
|
13
|
-
// per-request-injected object the way SQLAlchemy's
|
|
14
|
-
// its own DataSource reference directly (see
|
|
15
|
-
//
|
|
9
|
+
// argument through every METHOD CALL, matching FastAPI's own dependency-injection convention),
|
|
10
|
+
// none of fetch()/checkAccess()/patchField()/toPublic() below take a dataSource/session parameter
|
|
11
|
+
// -- TypeORM's `DataSource` is an app-wide singleton instantiated once at startup (`export const
|
|
12
|
+
// AppDataSource = new DataSource(...)`), not a per-request-injected object the way SQLAlchemy's
|
|
13
|
+
// `Session` is. Each generated resolver still imports its own DataSource reference directly (see
|
|
14
|
+
// resolver.ts.tmpl) and exposes it as the `dataSource` FIELD below (not a method parameter) --
|
|
15
|
+
// D-typescript-express-registry-parity's own infrastructure (recordSnapshotWrapper.ts, router.ts's
|
|
16
|
+
// recover()/enforcement logic) reads it from there rather than needing a separate reference of
|
|
17
|
+
// its own. `featureUid`/`contractRef` are the other two live-derived values
|
|
18
|
+
// D-resolver-policy-split exists for (see <Type>Policy.ts.tmpl). All three fields are read by
|
|
19
|
+
// that infrastructure only, never by fetch()/checkAccess()/patchField()/toPublic().
|
|
20
|
+
import type { DataSource } from 'typeorm';
|
|
21
|
+
|
|
16
22
|
export interface ResourceResolver {
|
|
17
23
|
type: string;
|
|
24
|
+
featureUid: string;
|
|
25
|
+
contractRef: string;
|
|
26
|
+
dataSource: DataSource;
|
|
18
27
|
fetch(resourceUid: string): Promise<unknown>;
|
|
19
28
|
checkAccess(obj: unknown): void;
|
|
20
29
|
patchField(obj: unknown, pointer: string, value: unknown): void;
|
|
@@ -24,9 +24,22 @@
|
|
|
24
24
|
import { {{MODEL}} } from '{{MODEL_IMPORT_PATH}}';
|
|
25
25
|
import { {{DATA_SOURCE_NAME}} } from '{{DATA_SOURCE_IMPORT_PATH}}';
|
|
26
26
|
import { register, HandleAccessDeniedError, HandleNotImplementedError, type ResourceResolver } from '../registry';
|
|
27
|
+
import { {{RESOURCE_TYPE}}Policy } from './{{RESOURCE_TYPE_CAMEL}}Policy';
|
|
27
28
|
|
|
28
29
|
const {{RESOURCE_TYPE}}Resolver: ResourceResolver = {
|
|
30
|
+
...{{RESOURCE_TYPE}}Policy,
|
|
31
|
+
// Redundant with the spread above (Policy already carries `type`) -- restated explicitly so
|
|
32
|
+
// this file's own orphan-scan (emit.mjs's resourceTypeOf(), a content-read, not a filename
|
|
33
|
+
// guess) can still find a real `type: 'X'` field without needing to open the sibling Policy
|
|
34
|
+
// file too. A literal, not derived from anything -- if this and the Policy value ever
|
|
35
|
+
// disagreed, the object-literal spread order means this line always wins at runtime, matching
|
|
36
|
+
// what the orphan scan would read.
|
|
29
37
|
type: '{{RESOURCE_TYPE}}',
|
|
38
|
+
// D-typescript-express-registry-parity: exposed here, not in the policy file -- this is an
|
|
39
|
+
// OBJECT REFERENCE (the same app-wide DataSource singleton fetch() already imports below), not
|
|
40
|
+
// a plain regenerable string value the way contractRef/featureUid are. Read by recordSnapshotWrapper.ts
|
|
41
|
+
// and router.ts's recover()/enforcement logic so they never need their own separate DataSource.
|
|
42
|
+
dataSource: {{DATA_SOURCE_NAME}},
|
|
30
43
|
|
|
31
44
|
async fetch(resourceUid: string): Promise<unknown> {
|
|
32
45
|
return {{DATA_SOURCE_NAME}}.getRepository({{MODEL}}).findOne({ where: { {{ID_FIELD}}: resourceUid } });
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Generated by backend-skeleton (bskel handles emit) for feature {{FEATURE_ID}}.
|
|
2
|
+
//
|
|
3
|
+
// D-typescript-express-registry-parity (mirrors java-spring's ResourceResolverPolicyStub.java.tmpl
|
|
4
|
+
// / D-resolver-policy-split): machine-owned companion to {{RESOURCE_TYPE}}Resolver -- every value
|
|
5
|
+
// here is derived directly from the current contract/scan and is safe to regenerate on every
|
|
6
|
+
// `bskel handles emit` run, even when the sibling resolver file has diverged (e.g. a hand-finished
|
|
7
|
+
// patchField()) and is refusing to regenerate. This is a SEPARATE file, not a section of
|
|
8
|
+
// resolver.ts, so a hand-edit to checkAccess()/patchField() there never blocks a live-derived
|
|
9
|
+
// value here (contractRef/featureUid) from updating -- both used to live in one file for this
|
|
10
|
+
// provider's own 1st slice, and classifyFile()'s conflict detection is file-level.
|
|
11
|
+
//
|
|
12
|
+
// Nothing here is ever meant to be hand-edited -- to change one of these values, change the
|
|
13
|
+
// SOURCE it was derived from (the feature contract, etc.) and re-run `bskel handles emit`. A
|
|
14
|
+
// hand-edit here will be silently overwritten on the next run, by design (see the "provably
|
|
15
|
+
// untouched -> safe to regenerate" path in lib/handles-manifest.mjs's classifyFile()).
|
|
16
|
+
export const {{RESOURCE_TYPE}}Policy = {
|
|
17
|
+
type: '{{RESOURCE_TYPE}}',
|
|
18
|
+
contractRef: '{{CONTRACT_REF}}',
|
|
19
|
+
featureUid: '{{FEATURE_UID}}',
|
|
20
|
+
} as const;
|
|
@@ -9,8 +9,38 @@
|
|
|
9
9
|
// against still pins this version) does NOT auto-catch a rejected Promise inside an async route
|
|
10
10
|
// handler the way Express 5 does, and that can't be assumed of an arbitrary target app either way.
|
|
11
11
|
import { Router, type Request, type Response } from 'express';
|
|
12
|
-
import { decodeHandle, resolveJsonPointer } from './codec';
|
|
13
|
-
import { resolverFor, HandleAccessDeniedError, HandleNotImplementedError } from './registry';
|
|
12
|
+
import { decodeHandle, resolveJsonPointer, deriveHandleUid, type DecodedHandle } from './codec';
|
|
13
|
+
import { resolverFor, HandleAccessDeniedError, HandleNotImplementedError, type ResourceResolver } from './registry';
|
|
14
|
+
import { HandleRegistry, HandleSnapshot } from './handleEntities';
|
|
15
|
+
|
|
16
|
+
// O3 (D-handle-registry-enforcement, part 2 of 2), D-typescript-express-registry-parity: opt-in,
|
|
17
|
+
// defaults false -- baked in at `bskel handles emit --enforce-registry on` time, matching every
|
|
18
|
+
// other per-target-repo codegen constant this project bakes in (e.g. featureUid/contractRef on
|
|
19
|
+
// each resolver). `registerHandle()` is only ever called by generated code wrapped with
|
|
20
|
+
// recordSnapshot(...) (an app that opted in) or a target app's own hand-written calls -- an app
|
|
21
|
+
// that mints handles through neither path gets fully working fetch()/patch() with zero registry
|
|
22
|
+
// involvement today; flipping this on unconditionally would 404 every one of their existing
|
|
23
|
+
// handles. Stays a deliberate, informed choice a target app's own maintainer makes.
|
|
24
|
+
const ENFORCE_REGISTRY = {{ENFORCE_REGISTRY}};
|
|
25
|
+
|
|
26
|
+
export class HandleNotRegisteredError extends Error {}
|
|
27
|
+
|
|
28
|
+
// D-handle-registry-enforcement's own bootstrapping-trap fix, ported here from day one (not
|
|
29
|
+
// rediscovered): always derives and looks up the PARENT RESOURCE's own row (kind='r',
|
|
30
|
+
// pointer=null), regardless of the requested handle's own kind/pointer -- `resourceType` is still
|
|
31
|
+
// exactly cross-checked (the real D-security-9 protection: an attacker-controlled `type` can't
|
|
32
|
+
// claim a different, more sensitive resource's registration by UUID coincidence), but kind/pointer
|
|
33
|
+
// are not, since every real registration in this system is resource-level. Structurally correct,
|
|
34
|
+
// not a workaround: revocation is inherently a resource-level concept, matching how fetch()'s own
|
|
35
|
+
// kind=f path already fetches the WHOLE resource and narrows client-side with a pointer.
|
|
36
|
+
async function requireRegisteredOrThrow(resolver: ResourceResolver, decoded: DecodedHandle): Promise<HandleRegistry> {
|
|
37
|
+
const resourceHandleUid = deriveHandleUid('r', decoded.type, decoded.uuid, null);
|
|
38
|
+
const registry = await resolver.dataSource.getRepository(HandleRegistry).findOne({ where: { handleUid: resourceHandleUid } });
|
|
39
|
+
if (registry == null || registry.resourceType !== decoded.type || registry.revokedAt != null) {
|
|
40
|
+
throw new HandleNotRegisteredError('no active registration for this handle');
|
|
41
|
+
}
|
|
42
|
+
return registry;
|
|
43
|
+
}
|
|
14
44
|
|
|
15
45
|
export const router = Router();
|
|
16
46
|
|
|
@@ -38,6 +68,9 @@ router.get('/handles/:handle', async (req: Request, res: Response) => {
|
|
|
38
68
|
}
|
|
39
69
|
|
|
40
70
|
try {
|
|
71
|
+
if (ENFORCE_REGISTRY) {
|
|
72
|
+
await requireRegisteredOrThrow(resolver, decoded);
|
|
73
|
+
}
|
|
41
74
|
const obj = await resolver.fetch(decoded.uuid);
|
|
42
75
|
if (obj == null) {
|
|
43
76
|
res.status(404).json({ detail: 'resource not found' });
|
|
@@ -65,6 +98,10 @@ router.get('/handles/:handle', async (req: Request, res: Response) => {
|
|
|
65
98
|
res.status(403).json({ detail: exc.message });
|
|
66
99
|
return;
|
|
67
100
|
}
|
|
101
|
+
if (exc instanceof HandleNotRegisteredError) {
|
|
102
|
+
res.status(404).json({ detail: exc.message });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
68
105
|
throw exc;
|
|
69
106
|
}
|
|
70
107
|
});
|
|
@@ -100,6 +137,9 @@ router.patch('/handles/:handle', async (req: Request, res: Response) => {
|
|
|
100
137
|
}
|
|
101
138
|
|
|
102
139
|
try {
|
|
140
|
+
if (ENFORCE_REGISTRY) {
|
|
141
|
+
await requireRegisteredOrThrow(resolver, decoded);
|
|
142
|
+
}
|
|
103
143
|
const obj = await resolver.fetch(decoded.uuid);
|
|
104
144
|
if (obj == null) {
|
|
105
145
|
res.status(404).json({ detail: 'resource not found' });
|
|
@@ -117,6 +157,77 @@ router.patch('/handles/:handle', async (req: Request, res: Response) => {
|
|
|
117
157
|
res.status(501).json({ detail: exc.message });
|
|
118
158
|
return;
|
|
119
159
|
}
|
|
160
|
+
if (exc instanceof HandleNotRegisteredError) {
|
|
161
|
+
res.status(404).json({ detail: exc.message });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
throw exc;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// D-handle-lifecycle (O4), D-typescript-express-registry-parity: recover() structurally REQUIRES
|
|
169
|
+
// a registry row to find a snapshot's own primary key -- there is no "unenforced" mode for it,
|
|
170
|
+
// unlike fetch()/patch() above. Always calls the same shared cross-check those two now optionally
|
|
171
|
+
// call, so the two paths can never silently drift apart. Unlike fetch()/patch(), this does NOT
|
|
172
|
+
// call resolver.fetch() first -- recovering history of a since-deleted resource is legitimate, so
|
|
173
|
+
// this never requires the live resource to still exist.
|
|
174
|
+
router.get('/handles/:handle/recover', async (req: Request, res: Response) => {
|
|
175
|
+
if (typeof req.params.handle !== 'string') {
|
|
176
|
+
res.status(400).json({ detail: 'malformed handle path segment' });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
let decoded;
|
|
180
|
+
try {
|
|
181
|
+
decoded = decodeHandle(req.params.handle);
|
|
182
|
+
} catch (exc) {
|
|
183
|
+
res.status(400).json({ detail: (exc as Error).message });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const resolver = resolverFor(decoded.type);
|
|
188
|
+
if (!resolver) {
|
|
189
|
+
res.status(404).json({ detail: `no resolver registered for handle type "${decoded.type}"` });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let registry: HandleRegistry;
|
|
194
|
+
try {
|
|
195
|
+
registry = await requireRegisteredOrThrow(resolver, decoded);
|
|
196
|
+
} catch (exc) {
|
|
197
|
+
if (exc instanceof HandleNotRegisteredError) {
|
|
198
|
+
res.status(404).json({ detail: exc.message });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
120
201
|
throw exc;
|
|
121
202
|
}
|
|
203
|
+
|
|
204
|
+
const atParam = typeof req.query.at === 'string' ? req.query.at : null;
|
|
205
|
+
const at = atParam != null ? new Date(atParam) : null;
|
|
206
|
+
if (at != null && Number.isNaN(at.getTime())) {
|
|
207
|
+
res.status(400).json({ detail: `"at" query parameter is not a valid date: "${atParam}"` });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const qb = resolver.dataSource.getRepository(HandleSnapshot)
|
|
212
|
+
.createQueryBuilder('snapshot')
|
|
213
|
+
.where('snapshot.handle_uid = :handleUid', { handleUid: registry.handleUid })
|
|
214
|
+
.orderBy('snapshot.recorded_at', 'DESC');
|
|
215
|
+
if (at != null) qb.andWhere('snapshot.recorded_at <= :at', { at });
|
|
216
|
+
const snapshot = await qb.getOne();
|
|
217
|
+
|
|
218
|
+
if (snapshot == null) {
|
|
219
|
+
res.status(404).json({ detail: `no snapshot recorded for this handle${at != null ? ` at or before ${at.toISOString()}` : ''}` });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
res.status(200).json({
|
|
224
|
+
handle: req.params.handle,
|
|
225
|
+
recordedAt: snapshot.recordedAt,
|
|
226
|
+
operationId: snapshot.operationId,
|
|
227
|
+
// snapshot.payload is already a native object/array/scalar (a real jsonb column) -- no
|
|
228
|
+
// manual re-parse step exists here for the double-encoding bug Java once had to be possible
|
|
229
|
+
// in the first place.
|
|
230
|
+
payload: snapshot.payload,
|
|
231
|
+
schemaDrift: registry.contractRef !== snapshot.contractHash,
|
|
232
|
+
});
|
|
122
233
|
});
|
|
@@ -9,10 +9,13 @@ export const provider = {
|
|
|
9
9
|
id: 'typescript-express',
|
|
10
10
|
title: 'TypeScript / Express / TypeORM',
|
|
11
11
|
requiresCapabilities: ['resource.fetch'],
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
12
|
+
// D-typescript-express-registry-parity: migration.sql is manifest-tracked now (like every
|
|
13
|
+
// other provider's, per D-write-safety-phase0), so this declaration is NOT for idempotence
|
|
14
|
+
// exclusion -- it's lib/verify.mjs's S6 safety net (a `handles ran` check that fires even with
|
|
15
|
+
// no manifest entry at all, e.g. a `gate force`d handles gate). See java-spring.mjs's own
|
|
16
|
+
// identical comment for the full reasoning; checkArtifacts() dedupes against the manifest-
|
|
17
|
+
// based check by path, so this never produces a duplicate once a real manifest entry exists.
|
|
18
|
+
outputs: { spec: ['handles/migration.sql'] },
|
|
16
19
|
plan,
|
|
17
20
|
emit(args) {
|
|
18
21
|
return emitTypeScriptExpress(args);
|
package/lib/cli.mjs
CHANGED
|
@@ -306,11 +306,15 @@ export const COMMANDS = {
|
|
|
306
306
|
},
|
|
307
307
|
},
|
|
308
308
|
'stack apply': {
|
|
309
|
-
usage: 'bskel stack apply --choice <id> [--apply] [--port N] [--json]',
|
|
309
|
+
usage: 'bskel stack apply --choice <id> [--apply] [--port N] [--force --reason "..."] [--json]',
|
|
310
310
|
options: {
|
|
311
311
|
choice: { type: 'string', default: null },
|
|
312
312
|
apply: { type: 'boolean', default: false },
|
|
313
313
|
port: { type: 'string', default: '8080', numeric: { min: 1, max: 65535 } },
|
|
314
|
+
// D-write-safety-phase0 (item 2): mirrors handles emit's own --force/--reason exactly --
|
|
315
|
+
// a file that diverged from what `stack apply` itself last wrote is refused without this.
|
|
316
|
+
force: { type: 'boolean', default: false },
|
|
317
|
+
reason: { type: 'string', default: '' },
|
|
314
318
|
json: { type: 'boolean', default: false },
|
|
315
319
|
},
|
|
316
320
|
},
|
|
@@ -359,11 +363,16 @@ export const COMMANDS = {
|
|
|
359
363
|
// purpose IS the live query). Reuses `--resource type1,type2`'s exact multi-value convention
|
|
360
364
|
// from `handles plan`/`handles emit` rather than a new singular flag name.
|
|
361
365
|
'handles audit': {
|
|
362
|
-
usage: 'bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--json]',
|
|
366
|
+
usage: 'bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--module <name>] [--check-registry-coverage] [--json]',
|
|
363
367
|
options: {
|
|
364
368
|
feature: { type: 'string', default: null, required: true },
|
|
365
369
|
'database-url-env': { type: 'string', default: null, required: true },
|
|
366
370
|
resource: { type: 'string', default: '' },
|
|
371
|
+
// D-write-safety-phase1 (item 3): both opt-in, only consulted when --check-registry-coverage
|
|
372
|
+
// is set -- the base command stays gate-independent and scan-report-independent otherwise
|
|
373
|
+
// (see this command's own doc comment in bin/bskel.mjs for why that matters).
|
|
374
|
+
module: { type: 'string', default: null },
|
|
375
|
+
'check-registry-coverage': { type: 'boolean', default: false },
|
|
367
376
|
json: { type: 'boolean', default: false },
|
|
368
377
|
},
|
|
369
378
|
},
|
package/lib/exit-codes.mjs
CHANGED
|
@@ -22,6 +22,23 @@ export const EXIT_CODES = Object.freeze({
|
|
|
22
22
|
LOW_CONFIDENCE_SCAN: 16,
|
|
23
23
|
MISSING_CAPABILITY: 17,
|
|
24
24
|
REFRESH_FAILED: 18,
|
|
25
|
+
// D-write-safety-phase0 (item 2): `stack apply --apply` refusing to overwrite a file that
|
|
26
|
+
// diverged from what backend-skeleton itself last wrote (without --force --reason) --
|
|
27
|
+
// deliberately its own code, not a reuse of HANDLES_CONFLICT, since this is a different write
|
|
28
|
+
// surface (stack/apply.mjs, not handles/_engine.mjs) and D-stable-api-contract's exit-code
|
|
29
|
+
// promise is additive-safe for new codes.
|
|
30
|
+
STACK_CONFLICT: 19,
|
|
31
|
+
// D-write-safety-phase1 (item 1): `handles emit --enforce-registry on` refusing to proceed
|
|
32
|
+
// because the target java-spring repo's build file has no `spring-boot-starter-aop` --
|
|
33
|
+
// HandleAspect.java cannot intercept anything without it, so writing the resolvers would be
|
|
34
|
+
// silently unusable. Not a reuse of MISSING_CAPABILITY(17), which means "the ADAPTER doesn't
|
|
35
|
+
// declare this capability at all" -- this repo's adapter does, it's just one dependency short.
|
|
36
|
+
HANDLES_MISSING_DEPENDENCY: 20,
|
|
37
|
+
// D-write-safety-phase1 (item 2): `handles emit --enforce-registry on` refusing to proceed
|
|
38
|
+
// because a resource has no @RecordHandleSnapshot/@record_snapshot anywhere the static check
|
|
39
|
+
// can find -- distinct from HANDLES_CONFLICT(15), which is about a GENERATED file diverging;
|
|
40
|
+
// this is about a hand-written file bskel never touches lacking an annotation it can't add.
|
|
41
|
+
HANDLES_REGISTRATION_GAP: 21,
|
|
25
42
|
});
|
|
26
43
|
|
|
27
44
|
// `reason` values a `sbf.cli-diagnostic/1` envelope (lib/cli.mjs) can carry. Deliberately does
|
|
@@ -55,6 +72,10 @@ export const EXIT_REASONS = Object.freeze({
|
|
|
55
72
|
ADAPTER_UNAVAILABLE: EXIT_CODES.NOT_PASSED,
|
|
56
73
|
PROVIDER_UNAVAILABLE: EXIT_CODES.NOT_PASSED,
|
|
57
74
|
UNKNOWN_OPERATION: EXIT_CODES.NOT_PASSED,
|
|
75
|
+
// D-openapi-cyclic-refs: `contract tool-schema`'s own refusal when the operation's payload
|
|
76
|
+
// schema is genuinely recursive -- Anthropic's tool-use input_schema explicitly does not
|
|
77
|
+
// support recursive schemas (a real, documented API limitation, not a bskel-side gap).
|
|
78
|
+
RECURSIVE_SCHEMA_UNSUPPORTED: EXIT_CODES.NOT_PASSED,
|
|
58
79
|
SCAN_FAILED: EXIT_CODES.NOT_PASSED,
|
|
59
80
|
PLAN_FAILED: EXIT_CODES.NOT_PASSED,
|
|
60
81
|
});
|
package/lib/verify.mjs
CHANGED
|
@@ -110,24 +110,33 @@ function handlesOutputsFor(root, featureId) {
|
|
|
110
110
|
// has ever run at all, independent of its current pass/stale status -- a stale or forced handles
|
|
111
111
|
// gate still implies every one of its expected outputs should exist. A provider with zero
|
|
112
112
|
// spec-scoped outputs simply produces no artifact items here at all, which is correct: there is
|
|
113
|
-
// nothing to check (no provider currently declares
|
|
114
|
-
//
|
|
115
|
-
//
|
|
113
|
+
// nothing to check (no provider currently declares a non-empty outputs.spec any more --
|
|
114
|
+
// D-write-safety-phase0 (item 1) moved java-spring/python-fastapi's migration.sql onto manifest
|
|
115
|
+
// tracking, the same mechanism handlesManifestChecks() below already covers, so this loop is now
|
|
116
|
+
// live only for the fallback case below or a future provider with a genuinely untracked output).
|
|
116
117
|
export function checkArtifacts(root, featureId, gates = []) {
|
|
117
118
|
const checks = [];
|
|
118
119
|
const contractPath = specPath(root, featureId, 'contracts', `${featureId}.schema.json`);
|
|
119
120
|
checks.push({ artifact: 'contract', path: path.relative(root, contractPath), exists: fs.existsSync(contractPath) });
|
|
120
121
|
|
|
121
122
|
const handlesRan = gates.find((g) => g.gate === 'handles')?.ran ?? false;
|
|
123
|
+
const manifestChecks = handlesManifestChecks(root, featureId, handlesRan);
|
|
124
|
+
// D-write-safety-phase0 (item 1): a path already covered by the manifest-based check above must
|
|
125
|
+
// not also get the legacy existence-only check below -- migration.sql is now BOTH manifest-
|
|
126
|
+
// tracked (owner: featureId) AND still named in DEFAULT_HANDLES_OUTPUTS's fallback list, so
|
|
127
|
+
// without this guard a scan-report-missing/corrupt repo would report it twice.
|
|
128
|
+
const manifestCheckedPaths = new Set(manifestChecks.map((c) => c.path));
|
|
122
129
|
for (const relOutput of handlesOutputsFor(root, featureId)) {
|
|
123
130
|
const outputPath = specPath(root, featureId, ...relOutput.split('/'));
|
|
131
|
+
const outputRelPath = path.relative(root, outputPath);
|
|
132
|
+
if (manifestCheckedPaths.has(outputRelPath)) continue;
|
|
124
133
|
const outputExists = fs.existsSync(outputPath);
|
|
125
134
|
if (handlesRan || outputExists) {
|
|
126
135
|
const label = path.basename(relOutput, path.extname(relOutput));
|
|
127
|
-
checks.push({ artifact: `handles ${label}`, path:
|
|
136
|
+
checks.push({ artifact: `handles ${label}`, path: outputRelPath, exists: outputExists });
|
|
128
137
|
}
|
|
129
138
|
}
|
|
130
|
-
checks.push(...
|
|
139
|
+
checks.push(...manifestChecks);
|
|
131
140
|
return checks;
|
|
132
141
|
}
|
|
133
142
|
|
|
@@ -138,6 +147,11 @@ export function checkArtifacts(root, featureId, gates = []) {
|
|
|
138
147
|
// nothing regenerates it implicitly, and the feature doesn't compile without it. Same mechanism
|
|
139
148
|
// and reasoning as the migration.sql check above (S6) -- existence only, at verify time, entirely
|
|
140
149
|
// outside the gate token.
|
|
150
|
+
// D-write-safety-phase0 (item 1): manifest 'kind' values -> the label bskel verify's report shows.
|
|
151
|
+
// Anything not listed falls back to 'handles resolver' (the historical default before 'migration'
|
|
152
|
+
// existed) -- see the artifact: line below.
|
|
153
|
+
const HANDLES_ARTIFACT_LABELS = { infra: 'handles infra', migration: 'handles migration' };
|
|
154
|
+
|
|
141
155
|
function handlesManifestChecks(root, featureId, handlesRan) {
|
|
142
156
|
let manifest;
|
|
143
157
|
try {
|
|
@@ -162,7 +176,10 @@ function handlesManifestChecks(root, featureId, handlesRan) {
|
|
|
162
176
|
.map(([relPath, e]) => {
|
|
163
177
|
const abs = resolveWithinRoot(root, relPath);
|
|
164
178
|
return {
|
|
165
|
-
|
|
179
|
+
// D-write-safety-phase0 (item 1): was a binary ternary (infra vs. everything else
|
|
180
|
+
// called "resolver") -- widened once migration.sql started manifest-tracking as its
|
|
181
|
+
// own kind: 'migration', which is neither infra (repo-owned) nor a resolver.
|
|
182
|
+
artifact: HANDLES_ARTIFACT_LABELS[e.kind] ?? 'handles resolver',
|
|
166
183
|
path: relPath,
|
|
167
184
|
exists: abs !== null && fs.existsSync(abs),
|
|
168
185
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-skeleton",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"test:java-compile": "node scripts/java-compile-smoke.mjs",
|
|
39
39
|
"test:python-import": "node scripts/python-import-smoke.mjs",
|
|
40
40
|
"test:db-introspect": "node scripts/db-introspect-smoke.mjs",
|
|
41
|
+
"test:registry-coverage": "node scripts/handles-registry-coverage-smoke.mjs",
|
|
41
42
|
"test:ddl-apply": "node scripts/ddl-apply-smoke.mjs",
|
|
42
43
|
"test:cross-feature-fk": "node scripts/cross-feature-fk-smoke.mjs",
|
|
43
44
|
"test:java-integration": "node scripts/java-integration-smoke.mjs",
|
|
@@ -45,7 +46,9 @@
|
|
|
45
46
|
"test:python-integration": "node scripts/python-integration-smoke.mjs",
|
|
46
47
|
"test:typescript-compile": "node scripts/typescript-typecheck-smoke.mjs",
|
|
47
48
|
"test:spring-initializr-canary": "node scripts/spring-initializr-canary.mjs",
|
|
48
|
-
"test:
|
|
49
|
+
"test:shadow-validation": "node scripts/shadow-validation-smoke.mjs",
|
|
50
|
+
"test:oracle-corpus": "node scripts/shadow-validation-smoke.mjs --manifest test/fixtures/oracle-manifest.json",
|
|
51
|
+
"test:all-smoke": "npm test && npm run test:pack && npm run test:java-compile && npm run test:java-integration && npm run test:python-import && npm run test:python-integration && npm run test:db-introspect && npm run test:registry-coverage && npm run test:ddl-apply && npm run test:cross-feature-fk && npm run test:java-ast && npm run test:typescript-compile"
|
|
49
52
|
},
|
|
50
53
|
"dependencies": {
|
|
51
54
|
"ajv": "^8.20.0",
|
|
@@ -53,7 +53,15 @@ export function matchBalanced(text, openIndex, openChar, closeChar) {
|
|
|
53
53
|
return -1;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// D-entity-id-field-inheritance: `\b` before `(class|record)` -- found live, not anticipated,
|
|
57
|
+
// while verifying a real cross-file inheritance walk against `@MappedSuperclass`-annotated Java
|
|
58
|
+
// (spring-petclinic's own `BaseEntity`). Without it, `class`/`record` could match as a SUBSTRING of
|
|
59
|
+
// a preceding identifier that happens to end the same way -- `@MappedSuperclass\npublic class
|
|
60
|
+
// BaseEntity` matched "class" inside "Superclass" itself (no `\w`-to-non-`\w` transition exists
|
|
61
|
+
// between "Super" and "class", so no real word boundary there either way), then captured the next
|
|
62
|
+
// bare word ("public") as if it were the class name. `\s+` alone was never sufficient protection --
|
|
63
|
+
// it only requires whitespace AFTER the match, never a real word start before it.
|
|
64
|
+
const CLASS_OR_RECORD_RE = /(?:public\s+)?\b(class|record)\s+(\w+)/;
|
|
57
65
|
|
|
58
66
|
// Adds `record` support and makes `public` optional (a package-private class is still a legal
|
|
59
67
|
// Spring bean) in the one place both extractController() and extractEntity() already duplicate
|
|
@@ -141,7 +149,22 @@ const MAPPING_VERBS = ['Get', 'Post', 'Put', 'Patch', 'Delete'];
|
|
|
141
149
|
const MAPPING_ANNOTATION_RE = new RegExp(`@(?:(${MAPPING_VERBS.join('|')})Mapping|RequestMapping)\\b`, 'g');
|
|
142
150
|
const REQUEST_MAPPING_RE = /@RequestMapping\b/g;
|
|
143
151
|
const CLASS_OR_RECORD_START_RE = /^(?:public\s+)?(?:class|record)\b/;
|
|
144
|
-
|
|
152
|
+
// D-java-spring-static-import-method: `RequestMethod.` prefix made optional -- confirmed live,
|
|
153
|
+
// dogfooding against a real, popular repo (gothinkster/spring-boot-realworld-example-app, 1,584
|
|
154
|
+
// real GitHub stars): its UsersApi.java (register + login, 2 of the RealWorld spec's most
|
|
155
|
+
// fundamental endpoints) uses `import static ... RequestMethod.POST;` then bare
|
|
156
|
+
// `@RequestMapping(path = "/users", method = POST)` -- a real, common Java style (static-import a
|
|
157
|
+
// single enum constant to cut the qualifier) the original prefix-required regex silently missed
|
|
158
|
+
// (0/2 endpoints on this file; the OTHER 15/17 endpoints in this same corpus, using
|
|
159
|
+
// @PostMapping/@GetMapping shorthand elsewhere, were unaffected and already correct). The verb
|
|
160
|
+
// alternation is restricted to Spring's own real `RequestMethod` enum's 8 actual values (GET,
|
|
161
|
+
// HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE) rather than a bare `\w+` -- narrower than
|
|
162
|
+
// "any identifier", so an unrelated `method = someVariable` still correctly fails to match rather
|
|
163
|
+
// than being misread as a verb. Verified live: the existing multi-verb array form (`method =
|
|
164
|
+
// {RequestMethod.GET, RequestMethod.POST}`, still deliberately unresolved/skipped) does NOT
|
|
165
|
+
// accidentally partial-match here -- the array's own `{` breaks the match before any verb name is
|
|
166
|
+
// reached, same as before this change.
|
|
167
|
+
const REQUEST_MAPPING_METHOD_RE = /\bmethod\s*=\s*(?:RequestMethod\.)?(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|TRACE)\b/;
|
|
145
168
|
|
|
146
169
|
// True when `class`/`record` is the next real declaration after `index`, ONE OR MORE further
|
|
147
170
|
// annotations allowed in between (e.g. a real oracle shape: `@RequestMapping(...)
|
|
@@ -45,10 +45,43 @@ function listJavaFiles(srcRoot) {
|
|
|
45
45
|
return out.split('\n').filter(Boolean).sort();
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// D-module-attribution-base-package: found live via a real-world corpus check (spring-projects/
|
|
49
|
+
// spring-petclinic, the canonical public Spring Boot reference app) -- `domain/<module>/...` is
|
|
50
|
+
// Team-IZ-Backend's OWN package convention, not a general Spring Boot one; petclinic's real
|
|
51
|
+
// packages (`org.springframework.samples.petclinic.owner`, `...vet`, `...system`) never contain a
|
|
52
|
+
// `domain` segment at all, so every entity there collapsed into `_unknown`. Finds the app's real
|
|
53
|
+
// base package via its `@SpringBootApplication` class -- the actual framework-defined component-
|
|
54
|
+
// scan root, not a second guessed folder name -- and returns the first path segment immediately
|
|
55
|
+
// below it as a fallback module name. A single targeted `rg` search (not a second full-file read
|
|
56
|
+
// pass), matching this file's own `listJavaFiles()` pattern for rg-invocation-with-graceful-empty-
|
|
57
|
+
// result.
|
|
58
|
+
function findBasePackage(srcRoot) {
|
|
59
|
+
let out;
|
|
60
|
+
try {
|
|
61
|
+
out = execFileSync('rg', ['-l', '--fixed-strings', '@SpringBootApplication', '-g', '*.java', srcRoot], { encoding: 'utf8' });
|
|
62
|
+
} catch {
|
|
63
|
+
return null; // rg exits 1 on "no match" -- not an error, just no @SpringBootApplication class found
|
|
64
|
+
}
|
|
65
|
+
const file = out.split('\n').filter(Boolean).sort()[0];
|
|
66
|
+
if (!file) return null;
|
|
67
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
68
|
+
const pkg = text.match(/(?:^|\n)\s*package\s+([\w.]+)\s*;/);
|
|
69
|
+
return pkg ? pkg[1].split('.') : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function moduleOf(filePath, srcRoot, basePackageParts) {
|
|
49
73
|
const parts = path.relative(srcRoot, filePath).split(path.sep);
|
|
50
74
|
const domainIdx = parts.indexOf('domain');
|
|
51
|
-
|
|
75
|
+
if (domainIdx >= 0 && parts[domainIdx + 1]) return parts[domainIdx + 1];
|
|
76
|
+
// Fallback: the first segment directly under the app's own base package (e.g. petclinic's
|
|
77
|
+
// `owner`/`vet`/`system`) -- only when that segment is itself a subpackage (a directory), never
|
|
78
|
+
// the base package's own top-level file (the `@SpringBootApplication` class itself, or any
|
|
79
|
+
// other file living directly in the base package with no feature module of its own).
|
|
80
|
+
if (basePackageParts && basePackageParts.length && parts.length > basePackageParts.length + 1) {
|
|
81
|
+
const matchesBasePackage = basePackageParts.every((seg, i) => parts[i] === seg);
|
|
82
|
+
if (matchesBasePackage) return parts[basePackageParts.length];
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
52
85
|
}
|
|
53
86
|
|
|
54
87
|
function extractQuotedOrValue(argsText) {
|
|
@@ -132,12 +165,46 @@ function extractController(text, filePath) {
|
|
|
132
165
|
return { className, basePath, operationIds, endpoints, file: filePath, line: classLine };
|
|
133
166
|
}
|
|
134
167
|
|
|
135
|
-
|
|
168
|
+
// D-entity-id-field-inheritance: found live against a real corpus check (spring-projects/
|
|
169
|
+
// spring-petclinic) -- `Owner extends Person extends BaseEntity`, and `@Id` lives on `BaseEntity`
|
|
170
|
+
// (a `@MappedSuperclass`), the standard, textbook JPA pattern for sharing an id/audit-field base
|
|
171
|
+
// across entities. A single-file-only `@Id` search misses it entirely for every entity built this
|
|
172
|
+
// way. `classIndex` (simple class name -> that file's own text, built once per scan in
|
|
173
|
+
// scanJavaSpring()) lets this walk the real `extends` chain instead of guessing.
|
|
174
|
+
function extendsClauseName(maskedText) {
|
|
175
|
+
const m = maskedText.match(/\bclass\s+\w+\s+extends\s+(\w+)/);
|
|
176
|
+
return m ? m[1] : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Depth-capped as insurance against a pathological/malformed input, not because real compilable
|
|
180
|
+
// Java can have circular inheritance (it can't) -- same "not expected to trigger, cheap insurance"
|
|
181
|
+
// reasoning javascript-express.mjs's own mount-graph cycle guard (`seen`) already uses for an
|
|
182
|
+
// analogous risk.
|
|
183
|
+
// D-write-safety-phase1 (item 4a): now also captures the declared TYPE, not just the field name --
|
|
184
|
+
// mirrors typescript-express.mjs's own already-established `idFieldIsUuid` precedent
|
|
185
|
+
// (scanners/adapters/typescript-express.mjs:216), which java-spring never had. Returns
|
|
186
|
+
// `{field, type} | null` (was a bare string | null) -- see extractEntity() below for how both
|
|
187
|
+
// existing and new consumers keep working from this.
|
|
188
|
+
function findIdField(text, classIndex, depth = 0) {
|
|
189
|
+
const direct = text.match(/@Id\b[\s\S]{0,200}?private\s+(\S+)\s+(\w+)\s*;/);
|
|
190
|
+
if (direct) return { field: direct[2], type: direct[1] };
|
|
191
|
+
if (depth >= 10) return null;
|
|
192
|
+
const superName = extendsClauseName(maskNonCode(text));
|
|
193
|
+
if (!superName || !classIndex.has(superName)) return null;
|
|
194
|
+
return findIdField(classIndex.get(superName), classIndex, depth + 1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Bare and fully-qualified both count -- a real repo can `import java.util.UUID;` or spell it out
|
|
198
|
+
// inline (`private java.util.UUID id;`), and this is a text-based scan with no import resolution.
|
|
199
|
+
function isUuidType(type) {
|
|
200
|
+
return type === 'UUID' || type === 'java.util.UUID';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function extractEntity(text, filePath, classIndex) {
|
|
136
204
|
if (!/@Entity\b/.test(text)) return null;
|
|
137
205
|
const masked = maskNonCode(text);
|
|
138
206
|
const classDecl = findClassOrRecordDeclaration(masked);
|
|
139
207
|
const tableMatch = text.match(/@Table\(\s*name\s*=\s*"([^"]+)"/);
|
|
140
|
-
const idFieldMatch = text.match(/@Id\b[\s\S]{0,200}?private\s+\S+\s+(\w+)\s*;/);
|
|
141
208
|
return {
|
|
142
209
|
className: classDecl ? classDecl.name : path.basename(filePath, '.java'),
|
|
143
210
|
table: tableMatch ? tableMatch[1] : null,
|
|
@@ -147,7 +214,20 @@ function extractEntity(text, filePath) {
|
|
|
147
214
|
// two adapters that DO guess (python-fastapi/typescript-express) -- cross-feature collision
|
|
148
215
|
// detection reads this field, not each adapter's own null-vs-guessed convention.
|
|
149
216
|
tableSource: tableMatch ? 'explicit' : null,
|
|
150
|
-
|
|
217
|
+
...(() => {
|
|
218
|
+
const id = findIdField(text, classIndex);
|
|
219
|
+
return {
|
|
220
|
+
idField: id?.field ?? null,
|
|
221
|
+
// D-write-safety-phase1 (item 4a): idFieldIsUuid mirrors typescript-express.mjs's own
|
|
222
|
+
// field name exactly, for the same reason it exists there -- the handles subsystem's
|
|
223
|
+
// entire identity model is UUID-addressable (fetch(UUID resourceUid), sbf1_ handle
|
|
224
|
+
// tokens encode a UUID), so a non-UUID PK means "no resolver, and say why" rather than
|
|
225
|
+
// silence. idFieldType is the extra, java-spring-specific detail (the actual declared
|
|
226
|
+
// type string) that lets the diagnostic name it, not just say "not UUID".
|
|
227
|
+
idFieldType: id?.type ?? null,
|
|
228
|
+
idFieldIsUuid: id ? isUuidType(id.type) : null,
|
|
229
|
+
};
|
|
230
|
+
})(),
|
|
151
231
|
file: filePath,
|
|
152
232
|
line: classDecl ? lineNumberAt(text, classDecl.index) : null,
|
|
153
233
|
};
|
|
@@ -239,6 +319,24 @@ export function scanJavaSpring(repoRoot) {
|
|
|
239
319
|
const srcRoot = detectJavaSpringRoot(repoRoot);
|
|
240
320
|
if (!srcRoot) return null;
|
|
241
321
|
|
|
322
|
+
const basePackage = findBasePackage(srcRoot);
|
|
323
|
+
|
|
324
|
+
// D-entity-id-field-inheritance: built once, not per-entity -- a single pass over the same
|
|
325
|
+
// files this function was already about to read anyway (no new file I/O), so
|
|
326
|
+
// extractEntity()'s idField search can walk a real `extends` chain across files.
|
|
327
|
+
const files = listJavaFiles(srcRoot);
|
|
328
|
+
const fileTexts = new Map();
|
|
329
|
+
const classIndex = new Map();
|
|
330
|
+
for (const file of files) {
|
|
331
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
332
|
+
fileTexts.set(file, text);
|
|
333
|
+
const decl = findClassOrRecordDeclaration(maskNonCode(text));
|
|
334
|
+
// First file wins on a same-simple-name collision across packages -- `files` is already
|
|
335
|
+
// sorted (listJavaFiles()'s own O6 determinism guarantee), so this is deterministic, not
|
|
336
|
+
// silently random; a documented, bounded limitation, not a general symbol resolver.
|
|
337
|
+
if (decl && !classIndex.has(decl.name)) classIndex.set(decl.name, text);
|
|
338
|
+
}
|
|
339
|
+
|
|
242
340
|
const modules = new Map();
|
|
243
341
|
const moduleEntry = (name) => {
|
|
244
342
|
const key = name ?? '_unknown';
|
|
@@ -246,16 +344,16 @@ export function scanJavaSpring(repoRoot) {
|
|
|
246
344
|
return modules.get(key);
|
|
247
345
|
};
|
|
248
346
|
|
|
249
|
-
for (const file of
|
|
250
|
-
const text =
|
|
251
|
-
const mod = moduleOf(file, srcRoot);
|
|
347
|
+
for (const file of files) {
|
|
348
|
+
const text = fileTexts.get(file);
|
|
349
|
+
const mod = moduleOf(file, srcRoot, basePackage);
|
|
252
350
|
|
|
253
351
|
if (/@RestController\b/.test(text)) {
|
|
254
352
|
const controller = extractController(text, file);
|
|
255
353
|
if (controller) moduleEntry(mod).controllers.push(controller);
|
|
256
354
|
}
|
|
257
355
|
if (/@Entity\b/.test(text)) {
|
|
258
|
-
const entity = extractEntity(text, file);
|
|
356
|
+
const entity = extractEntity(text, file, classIndex);
|
|
259
357
|
if (entity) moduleEntry(mod).entities.push(entity);
|
|
260
358
|
}
|
|
261
359
|
if (mod && file.includes(`${path.sep}domain${path.sep}`) && /public\s+enum\s+\w+/.test(text)) {
|
|
@@ -271,7 +369,7 @@ export function scanJavaSpring(repoRoot) {
|
|
|
271
369
|
// paths) -- this is what lib/gate-definitions.mjs's `scan` gate hashes to detect real content
|
|
272
370
|
// drift, and every other manifest-shaped gate input in this codebase (stack's `applied_file:`)
|
|
273
371
|
// is repo-relative too.
|
|
274
|
-
const filesRead =
|
|
372
|
+
const filesRead = files.map((f) => path.relative(repoRoot, f));
|
|
275
373
|
return { srcRoot, modules: [...modules.values()], pathPrefixSignals: detectGlobalPathPrefixSignals(repoRoot), filesRead };
|
|
276
374
|
}
|
|
277
375
|
|