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
@@ -0,0 +1,232 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { emitUnits, unifiedDiff } from '../../_engine.mjs';
5
+ import { loadPatchApprovals, approvedStrategyFor } from '../../../lib/patch-approvals.mjs';
6
+ import { computeCodegenNeeds, renderPatchFieldBody } from './patch-strategy.mjs';
7
+ import { sha256File } from '../../../lib/fsutil.mjs';
8
+ import { specPath } from '../../../lib/paths.mjs';
9
+ import { loadFeatureFile } from '../../../lib/featurelifecycle.mjs';
10
+
11
+ const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
12
+ const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
13
+ const MIGRATION_TEMPLATE = path.join(TEMPLATES_DIR, 'migration.sql.tmpl');
14
+ const RESOLVER_TEMPLATE = path.join(TEMPLATES_DIR, 'ResourceResolverStub.java.tmpl');
15
+
16
+ const INFRA_FILES = [
17
+ { template: 'HandleCodec.java.tmpl', target: 'global/handle/HandleCodec.java' },
18
+ { template: 'HandleRegistry.java.tmpl', target: 'global/handle/HandleRegistry.java' },
19
+ { template: 'HandleSnapshot.java.tmpl', target: 'global/handle/HandleSnapshot.java' },
20
+ { template: 'HandleRegistryRepository.java.tmpl', target: 'global/handle/HandleRegistryRepository.java' },
21
+ { template: 'HandleSnapshotRepository.java.tmpl', target: 'global/handle/HandleSnapshotRepository.java' },
22
+ { template: 'ResourceResolver.java.tmpl', target: 'global/handle/ResourceResolver.java' },
23
+ { template: 'HandleController.java.tmpl', target: 'global/handle/HandleController.java' },
24
+ // O4 (D-handle-lifecycle):
25
+ { template: 'HandleService.java.tmpl', target: 'global/handle/HandleService.java' },
26
+ { template: 'RecordHandleSnapshot.java.tmpl', target: 'global/handle/RecordHandleSnapshot.java' },
27
+ { template: 'HandleAspect.java.tmpl', target: 'global/handle/HandleAspect.java' },
28
+ ];
29
+
30
+ function render(templatePath, vars) {
31
+ let content = fs.readFileSync(templatePath, 'utf8');
32
+ for (const [key, value] of Object.entries(vars)) {
33
+ content = content.replaceAll(`{{${key}}}`, String(value));
34
+ }
35
+ return content;
36
+ }
37
+
38
+ function lowerFirst(s) {
39
+ return s.charAt(0).toLowerCase() + s.slice(1);
40
+ }
41
+
42
+ // A3 (D-patch-strategy): Spring Boot 4+ ships Jackson 3 as its primary JSON engine (package
43
+ // `tools.jackson.databind`, NOT `com.fasterxml.jackson.databind`) -- confirmed by reading the
44
+ // real oracle repo's own build.gradle (`id 'org.springframework.boot' version '4.1.0'`) AND its
45
+ // own source (`SecurityConfig.java` imports `tools.jackson.databind.ObjectMapper`). A single
46
+ // OTHER file in that same repo (`AiClient.java`) imports the classic `com.fasterxml.jackson`
47
+ // package for a third-party AI SDK's own bundled Jackson 2 instance -- NOT what Spring actually
48
+ // autoconfigures as the injectable `ObjectMapper` BEAN, so grepping "does any file mention this
49
+ // import" would have been ambiguous/wrong here. The Spring Boot plugin's own major version is the
50
+ // reliable signal: it determines which Jackson generation Spring's autoconfiguration wires up as
51
+ // the primary `ObjectMapper` bean, which is what `@RequiredArgsConstructor` injection needs.
52
+ // Defaults to the classic package when build.gradle/the plugin version can't be found -- covers
53
+ // the far more common Spring Boot <=3.x case, and matches this project's own CI fixture corpus.
54
+ export function detectJacksonPackage(repoRoot) {
55
+ const buildGradlePath = path.join(repoRoot, 'build.gradle');
56
+ if (!fs.existsSync(buildGradlePath)) return 'com.fasterxml.jackson.databind';
57
+ const text = fs.readFileSync(buildGradlePath, 'utf8');
58
+ const match = text.match(/id\s+['"]org\.springframework\.boot['"]\s+version\s+['"](\d+)\./);
59
+ const majorVersion = match ? Number(match[1]) : null;
60
+ return majorVersion !== null && majorVersion >= 4 ? 'tools.jackson.databind' : 'com.fasterxml.jackson.databind';
61
+ }
62
+
63
+ // The import block patchField() codegen needs, computed fresh every run
64
+ // from the CURRENT classification + approvals (never cached against a prior emit) -- empty string
65
+ // when nothing is approved yet, so a resource with no approved fields renders byte-identical to
66
+ // one with no update endpoint at all.
67
+ function buildPatchImports({ basePackage, module, dtoTypeName, needsPatchFieldImport, jacksonPackage }) {
68
+ const lines = [
69
+ `import ${basePackage}.domain.${module}.presentation.dto.${dtoTypeName};`,
70
+ 'import jakarta.validation.ConstraintViolation;',
71
+ 'import jakarta.validation.ConstraintViolationException;',
72
+ 'import jakarta.validation.Validator;',
73
+ `import ${jacksonPackage}.ObjectMapper;`,
74
+ 'import java.util.Set;',
75
+ ];
76
+ if (needsPatchFieldImport) lines.push(`import ${basePackage}.global.json.PatchField;`);
77
+ return lines.map((l) => `${l}\n`).join('');
78
+ }
79
+
80
+ function buildPatchFields() {
81
+ return '\tprivate final Validator validator;\n\tprivate final ObjectMapper objectMapper;\n';
82
+ }
83
+
84
+ // The set of {resource, field} approvals whose recorded strategy still matches what the
85
+ // classifier computes RIGHT NOW -- a stale approval (the DTO changed since approval) is excluded
86
+ // here, not just skipped at codegen time, so callers never even have to reason about staleness
87
+ // themselves. Fail-closed: a mismatch silently falls back to the "classified but not approved"
88
+ // explanatory stub, exactly like never having been approved at all.
89
+ function currentlyApprovedFields(approvals, resourceType, patchable) {
90
+ const approved = new Set();
91
+ for (const field of patchable) {
92
+ if (approvedStrategyFor(approvals, resourceType, field.field) === field.bucket) approved.add(field.field);
93
+ }
94
+ return approved;
95
+ }
96
+
97
+ function writeUnit(target, content) {
98
+ fs.mkdirSync(path.dirname(target), { recursive: true });
99
+ fs.writeFileSync(target, content);
100
+ }
101
+
102
+ // See DECISIONS.md D-handles-ownership for the full design; the conflict/manifest/force/orphan
103
+ // logic itself now lives in handles/_engine.mjs (D-handles-providers, G4) -- this function's job
104
+ // is purely to compute java-spring's own render/paths and hand them to emitUnits(). `force`/
105
+ // `reason` overwrite any conflicted unit found within this call's own scope (already narrowed by
106
+ // featureId/module/resourceFilter) -- never a blanket, unscoped force. `resourceFilter` (the same
107
+ // array plan() was called with, or null) turns off orphan detection when non-null, since a scoped
108
+ // run would otherwise report every OTHER resource's resolver as orphaned.
109
+ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false }) {
110
+ const javaSrcRoot = path.join(repoRoot, 'src', 'main', 'java', ...basePackage.split('.'));
111
+
112
+ // A3 (D-patch-strategy): loaded/detected once per emit call -- approvals are feature-scoped
113
+ // (not per-resource) and the Jackson package is repo-wide, so one computation each covers
114
+ // every resource/infra file this call touches (O4/D-handle-lifecycle: HandleService.java.tmpl
115
+ // now needs jacksonPackage too, for the same reason patch codegen already did).
116
+ const patchApprovals = loadPatchApprovals(repoRoot, featureId);
117
+ const jacksonPackage = detectJacksonPackage(repoRoot);
118
+
119
+ const infraUnits = INFRA_FILES.map((f) => ({
120
+ id: f.template,
121
+ templatePath: path.join(TEMPLATES_DIR, f.template),
122
+ targetAbs: path.join(javaSrcRoot, f.target),
123
+ rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage }),
124
+ }));
125
+
126
+ // O4 (D-handle-lifecycle): every resource in THIS feature shares the same contract file and
127
+ // feature_uid, so the common case only computes this once. requireNamedGate(root, 'contract',
128
+ // ...) already ran before emitJavaSpring() is ever reached (cmdHandlesEmit's own
129
+ // precondition), so this feature's own contract file is guaranteed to exist here.
130
+ //
131
+ // Deliberately NOT reused verbatim inside pristineRenderFor() below for a DIFFERENT owner --
132
+ // O2's cross-feature adoption check re-renders using the ORIGINAL owner's feature_id
133
+ // specifically to compare against what THAT feature would have generated; baking in the
134
+ // CURRENT run's own contract_ref/feature_uid there would compare disk content against the
135
+ // wrong feature's values and manufacture a false conflict for an untouched file. Falls back to
136
+ // null/placeholder if the other feature's own contract/feature file no longer exists (e.g. it
137
+ // was deleted) -- an edge case, not the common path, but must not throw.
138
+ const contractRefFor = (id) => sha256File(specPath(repoRoot, id, 'contracts', `${id}.schema.json`));
139
+ const featureUidFor = (id) => loadFeatureFile(repoRoot, id)?.feature_uid ?? '00000000-0000-0000-0000-000000000000';
140
+ const contractRef = contractRefFor(featureId);
141
+ const featureUid = featureUidFor(featureId);
142
+
143
+ const resolverUnits = plan.resources
144
+ .filter((r) => r.willGenerateResolver) // see plan.mjs: no broken imports generated on purpose
145
+ .map((resource) => {
146
+ const patchable = resource.patchable ?? [];
147
+ // A blocked update service (see plan.mjs's updateServiceBlockedReason) means NO field of
148
+ // this resource can be auto-generated regardless of approvals -- ignore any recorded
149
+ // approvals for its codegen-needs computation, but the classification itself still renders
150
+ // (see renderPatchFieldBody's blockedReason handling).
151
+ const approvedFields = resource.updateServiceBlockedReason ? new Set() : currentlyApprovedFields(patchApprovals, resource.type, patchable);
152
+ const { needsValidation, needsPatchFieldImport } = computeCodegenNeeds(patchable, approvedFields);
153
+ const serviceField = lowerFirst(resource.service.serviceType);
154
+ const vars = {
155
+ BASE_PACKAGE: basePackage,
156
+ MODULE: plan.module,
157
+ RESOURCE_TYPE: resource.type,
158
+ SERVICE_IMPORT: `${basePackage}.domain.${plan.module}.application.${resource.service.serviceType}`,
159
+ SERVICE_TYPE: resource.service.serviceType,
160
+ SERVICE_FIELD: serviceField,
161
+ FETCH_METHOD: resource.fetchOperation.method,
162
+ REQUIRED_AUTHORITY: resource.requiredAuthority,
163
+ FEATURE_ID: featureId,
164
+ CONTRACT_REF: contractRef,
165
+ FEATURE_UID: featureUid,
166
+ PATCH_IMPORTS: needsValidation ? buildPatchImports({ basePackage, module: plan.module, dtoTypeName: resource.dtoTypeName, needsPatchFieldImport, jacksonPackage }) : '',
167
+ PATCH_FIELDS: needsValidation ? buildPatchFields() : '',
168
+ PATCH_FIELD_BODY: renderPatchFieldBody({
169
+ resourceType: resource.type,
170
+ dtoTypeName: resource.dtoTypeName,
171
+ patchable,
172
+ updateOperation: resource.updateOperation,
173
+ serviceField,
174
+ approvedFields,
175
+ blockedReason: resource.updateServiceBlockedReason,
176
+ }),
177
+ };
178
+ return {
179
+ id: 'ResourceResolverStub.java.tmpl',
180
+ resourceType: resource.type,
181
+ module: plan.module,
182
+ templatePath: RESOLVER_TEMPLATE,
183
+ targetAbs: path.join(javaSrcRoot, 'domain', plan.module, 'infrastructure', `${resource.type}Resolver.java`),
184
+ rendered: render(RESOLVER_TEMPLATE, vars),
185
+ pristineRenderFor: (ownerId) => render(RESOLVER_TEMPLATE, {
186
+ ...vars,
187
+ FEATURE_ID: ownerId,
188
+ CONTRACT_REF: ownerId === featureId ? contractRef : contractRefFor(ownerId),
189
+ FEATURE_UID: ownerId === featureId ? featureUid : featureUidFor(ownerId),
190
+ }),
191
+ };
192
+ });
193
+
194
+ const orphanScan = (!resourceFilter && plan.module) ? {
195
+ dir: path.join(javaSrcRoot, 'domain', plan.module, 'infrastructure'),
196
+ module: plan.module,
197
+ matchesFile: (file) => file.endsWith('Resolver.java'),
198
+ resourceTypeOf: (file, _content) => file.replace(/Resolver\.java$/, ''),
199
+ } : null;
200
+
201
+ const result = emitUnits({ repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff });
202
+
203
+ // The migration file is regenerated fresh every run, unconditionally, regardless of the
204
+ // resolver/infra conflict-block state above -- it has never been manifest-tracked (no
205
+ // conflict detection for it at all), matching the pre-G4 behavior exactly. D4: this is exactly
206
+ // the `outputs.spec` category P4's conformance harness already had to special-case (see
207
+ // handles/conformance.mjs) -- classifyFile() never runs against it, so its create/unchanged/
208
+ // update action is derived locally here, tagged `kind: 'spec'` in the actions report so it
209
+ // reads distinctly from the manifest-tracked infra/resolver kinds.
210
+ const migrationContent = render(MIGRATION_TEMPLATE, { FEATURE_ID: featureId });
211
+ const migrationPath = path.join(repoRoot, 'specs', featureId, 'handles', 'migration.sql');
212
+ const migrationRelPath = path.relative(repoRoot, migrationPath);
213
+ const migrationDiskContent = fs.existsSync(migrationPath) ? fs.readFileSync(migrationPath, 'utf8') : null;
214
+ const migrationAction = migrationDiskContent === null ? 'create' : (migrationDiskContent === migrationContent ? 'unchanged' : 'update');
215
+ if (!dryRun) writeUnit(migrationPath, migrationContent);
216
+ result.written.push(migrationRelPath);
217
+ const migrationActionEntry = { path: migrationRelPath, kind: 'spec', action: migrationAction };
218
+ if (computeDiff && migrationAction === 'update') migrationActionEntry.diff = unifiedDiff(migrationRelPath, migrationDiskContent, migrationContent);
219
+ result.actions.push(migrationActionEntry);
220
+
221
+ return {
222
+ ...result,
223
+ postEmitNotes: [
224
+ 'NOT done automatically: applying specs/<id>/handles/migration.sql to any database. Review it and apply yourself.',
225
+ // O4 (D-handle-lifecycle): HandleAspect.java only actually intercepts anything once a
226
+ // human applies @RecordHandleSnapshot to a real service method AND the target repo has
227
+ // this dependency -- never auto-added to build.gradle, same "review and apply yourself"
228
+ // boundary as the migration note above.
229
+ 'NOT done automatically: HandleAspect.java requires spring-boot-starter-aop on your own build.gradle classpath (Spring AOP is not enabled by any other starter). Add it yourself before applying @RecordHandleSnapshot to any service method.',
230
+ ],
231
+ };
232
+ }
@@ -0,0 +1,229 @@
1
+ // A3 (D-patch-strategy): classifies each field of an update-request DTO record into one of the
2
+ // four partial-update conventions D-resolver-scope already found in the real oracle repo, from
3
+ // static analysis alone -- no guessing beyond what the DTO's own type/annotations declare.
4
+ //
5
+ // Reuses _java-spring-analyzer.mjs's maskNonCode()/matchBalanced()/skipAnnotationsAndWhitespace()
6
+ // (A2 Phase 1's proven infra) rather than duplicating regex-based Java parsing -- classification
7
+ // operates entirely on MASKED text; no original-text value (e.g. a @Schema description string) is
8
+ // ever needed to decide a field's bucket, only its type/annotation structure, which masking
9
+ // preserves 1:1 outside comments/string interiors.
10
+ import { maskNonCode, matchBalanced, findClassOrRecordDeclaration, skipAnnotationsAndWhitespace } from '../../../scanners/adapters/_java-spring-analyzer.mjs';
11
+
12
+ export const PATCH_STRATEGY = Object.freeze({
13
+ PATCH_WRAPPER: 'patch-wrapper',
14
+ NULL_MEANS_UNCHANGED: 'null-means-unchanged',
15
+ FETCH_MERGE_SUBMIT: 'fetch-merge-submit',
16
+ UNSUPPORTED: 'unsupported',
17
+ });
18
+
19
+ // Only the two buckets where reconstructing "everything else stays absent" carries no risk of
20
+ // silently carrying a stale sibling field -- see D-patch-strategy in DECISIONS.md for why
21
+ // fetch-merge-submit is deliberately excluded even though it's classified just as precisely.
22
+ export const CODEGEN_ELIGIBLE = Object.freeze([PATCH_STRATEGY.PATCH_WRAPPER, PATCH_STRATEGY.NULL_MEANS_UNCHANGED]);
23
+
24
+ // Primitive Java types can never be null -- a field declared as one of these cannot represent
25
+ // "omitted", so it can only ever mean "must always be resubmitted" (fetch-merge-submit), the same
26
+ // bucket a boxed-but-@NotNull type gets. No real DTO in the oracle repo's grounding used a
27
+ // primitive for a genuinely-optional field (Bean Validation on a partial-update DTO always uses
28
+ // the boxed type precisely so absence is representable) -- this matches that convention exactly
29
+ // rather than special-casing it.
30
+ const PRIMITIVE_TYPES = new Set(['int', 'long', 'short', 'byte', 'char', 'boolean', 'float', 'double']);
31
+
32
+ // Splits a record's parameter-list text (already masked) into its top-level components, treating
33
+ // BOTH `<...>` (generics) and `(...)` (annotation argument lists, e.g. `@Schema(description = "",
34
+ // nullable = true)`) as non-splitting depth. Deliberately NOT a reuse of plan.mjs's
35
+ // countTopLevelCommas() (D-security-8, only tracks `<`/`>`, locked/untouched by this item) --
36
+ // record components routinely carry parenthesized annotation args before the type, which that
37
+ // narrower helper was never built to handle, so this is a fresh, purpose-built splitter rather
38
+ // than stretching security-critical code to a new job.
39
+ export function splitTopLevelParams(maskedParamsText) {
40
+ const params = [];
41
+ let depth = 0;
42
+ let start = 0;
43
+ for (let i = 0; i < maskedParamsText.length; i++) {
44
+ const ch = maskedParamsText[i];
45
+ if (ch === '<' || ch === '(') depth++;
46
+ else if (ch === '>' || ch === ')') depth = Math.max(0, depth - 1);
47
+ else if (ch === ',' && depth === 0) {
48
+ params.push(maskedParamsText.slice(start, i));
49
+ start = i + 1;
50
+ }
51
+ }
52
+ const last = maskedParamsText.slice(start);
53
+ if (last.trim() !== '') params.push(last);
54
+ return params.map((p) => p.trim()).filter(Boolean);
55
+ }
56
+
57
+ // From one masked parameter segment (e.g. `PatchField<Long> monthlyTokenLimit` or `@NotNull
58
+ // OrganizationStatus status`), extracts { fieldName, baseType, generic, isArray } -- generic is
59
+ // the raw text inside `<...>` when present (e.g. "Long"), null otherwise. Returns null if the
60
+ // segment isn't shaped like `[annotations] Type[<...>][[]] name` at all (defensive -- should
61
+ // never happen against a real record component, but a malformed/unrecognized shape must fail
62
+ // closed to `unsupported`, never be silently misclassified).
63
+ export function extractTypeAndName(maskedSegment) {
64
+ const afterAnnotations = skipAnnotationsAndWhitespace(maskedSegment, 0);
65
+ const rest = maskedSegment.slice(afterAnnotations);
66
+ const idMatch = rest.match(/^[\w.]+/);
67
+ if (!idMatch) return null;
68
+ let i = idMatch[0].length;
69
+ let generic = null;
70
+ if (rest[i] === '<') {
71
+ const close = matchBalanced(rest, i, '<', '>');
72
+ if (close === -1) return null;
73
+ generic = rest.slice(i + 1, close).trim();
74
+ i = close + 1;
75
+ }
76
+ let isArray = false;
77
+ while (rest.slice(i, i + 2) === '[]') {
78
+ isArray = true;
79
+ i += 2;
80
+ }
81
+ const nameMatch = rest.slice(i).trim().match(/^(\w+)/);
82
+ if (!nameMatch) return null;
83
+ return { fieldName: nameMatch[1], baseType: idMatch[0], generic, isArray };
84
+ }
85
+
86
+ // The classification rule itself -- see D-patch-strategy in DECISIONS.md for the real DTOs each
87
+ // branch was confirmed against (UpdateOperationSettingRequest.monthlyTokenLimit for patch-wrapper,
88
+ // UpdateOrganizationRequest.status for fetch-merge-submit, UpdateClassroomManagersRequest.
89
+ // managerIds for unsupported, and the majority-case plain-nullable fields for null-means-unchanged).
90
+ function classifyParam(maskedSegment) {
91
+ const hasNotNull = /@NotNull\b/.test(maskedSegment);
92
+ const hasValid = /@Valid\b/.test(maskedSegment);
93
+ const parsed = extractTypeAndName(maskedSegment);
94
+ if (!parsed) return null;
95
+ const { fieldName, baseType, generic, isArray } = parsed;
96
+
97
+ let bucket;
98
+ let convertType = baseType;
99
+ if (baseType === 'PatchField') {
100
+ // PatchField<T> already IS the "presence-optional, null-has-meaning" convention -- takes
101
+ // priority over any co-occurring @NotNull, which would be a contradictory/unused combination
102
+ // never seen in the oracle repo's grounding.
103
+ bucket = PATCH_STRATEGY.PATCH_WRAPPER;
104
+ convertType = generic ?? 'Object';
105
+ } else if (isArray || hasValid || ['List', 'Set', 'Map'].includes(baseType)) {
106
+ bucket = PATCH_STRATEGY.UNSUPPORTED;
107
+ } else if (hasNotNull || PRIMITIVE_TYPES.has(baseType)) {
108
+ bucket = PATCH_STRATEGY.FETCH_MERGE_SUBMIT;
109
+ } else {
110
+ bucket = PATCH_STRATEGY.NULL_MEANS_UNCHANGED;
111
+ }
112
+
113
+ return { field: fieldName, javaType: baseType, generic, bucket, convertType };
114
+ }
115
+
116
+ // The blanket stub -- byte-identical to the pre-A3 template text, used whenever a resource has no
117
+ // classified patchable fields at all (no update endpoint found, no DTO resolved, or a non-record
118
+ // DTO). Keeping this exact wording means a resource that never gets an update endpoint stays
119
+ // completely unaffected by this item, not just functionally but textually.
120
+ function blanketStub(resourceType) {
121
+ return [
122
+ '\t\t// TODO: route through the real update method, matching whichever partial-update',
123
+ "\t\t// convention the target field's DTO actually uses. Do not write directly to the",
124
+ '\t\t// repository/entity -- that bypasses this codebase\'s existing validation and business',
125
+ '\t\t// rules, which is the entire reason handles route through the service layer instead of raw SQL.',
126
+ '\t\tthrow new UnsupportedOperationException(',
127
+ `\t\t\t\t"patchField not yet implemented for ${resourceType}" + pointer + " -- see this class's javadoc");`,
128
+ ].join('\n');
129
+ }
130
+
131
+ // One `case "/field" -> throw ...` explaining EXACTLY why this specific field isn't generated --
132
+ // D-patch-strategy's whole point is replacing "read three paragraphs and guess" with a precise
133
+ // per-field reason, so even the non-codegen branches stay field-specific, never lumped into one
134
+ // generic message.
135
+ function caseThrow(field, resourceType, reasonText) {
136
+ return `\t\t\tcase "/${field.field}" -> throw new UnsupportedOperationException(\n\t\t\t\t\t"patchField not auto-generated for ${resourceType}/${field.field} -- ${reasonText}");`;
137
+ }
138
+
139
+ function caseCodegen(field, { resourceType, dtoTypeName, updateOperation, serviceField, patchable }) {
140
+ const args = patchable.map((f) => {
141
+ if (f.field !== field.field) return 'null';
142
+ return f.bucket === PATCH_STRATEGY.PATCH_WRAPPER ? 'PatchField.of(convertedValue)' : 'convertedValue';
143
+ }).join(', ');
144
+ return [
145
+ `\t\t\tcase "/${field.field}" -> {`,
146
+ `\t\t\t\t${field.convertType} convertedValue = objectMapper.convertValue(value, ${field.convertType}.class);`,
147
+ `\t\t\t\t${dtoTypeName} patch = new ${dtoTypeName}(${args});`,
148
+ // O4 (D-handle-lifecycle): validateProperty, NOT validate(patch) -- a plain validate()
149
+ // checks every OTHER field on the reconstructed DTO too, so any @NotNull/primitive
150
+ // sibling (correctly classified as fetch-merge-submit, left null here on purpose) would
151
+ // ALWAYS fail validation for this field's own genuinely valid patch. Confirmed live: a
152
+ // real jakarta.validation.Validator run against this exact reconstruction threw on
153
+ // "ownerName" even when only "label" was being patched; validateProperty(patch, "${field.field}")
154
+ // scopes validation to just the field actually being changed, matching what a real
155
+ // single-field patch is supposed to validate.
156
+ `\t\t\t\tSet<ConstraintViolation<${dtoTypeName}>> violations = validator.validateProperty(patch, "${field.field}");`,
157
+ '\t\t\t\tif (!violations.isEmpty()) {',
158
+ '\t\t\t\t\tthrow new ConstraintViolationException(violations);',
159
+ '\t\t\t\t}',
160
+ `\t\t\t\t${serviceField}.${updateOperation.method}(resourceUid, patch);`,
161
+ '\t\t\t}',
162
+ ].join('\n');
163
+ }
164
+
165
+ // Whether this resource's generated resolver needs the validation/conversion machinery at all
166
+ // (Validator + ObjectMapper fields, their imports, Set/ConstraintViolation/
167
+ // ConstraintViolationException imports, the DTO type's own import) and separately whether it
168
+ // needs PatchField's import specifically -- both are ONLY true when at least one field actually
169
+ // gets real codegen (an approved, currently-matching, codegen-eligible field), never merely
170
+ // because a field is classified. A resource with fields classified but none approved yet renders
171
+ // zero extra imports/fields, keeping it identical to a resource with no update endpoint at all
172
+ // until a human actually approves something.
173
+ export function computeCodegenNeeds(patchable, approvedFields) {
174
+ const codegenFields = patchable.filter((f) => CODEGEN_ELIGIBLE.includes(f.bucket) && approvedFields.has(f.field));
175
+ return {
176
+ needsValidation: codegenFields.length > 0,
177
+ needsPatchFieldImport: codegenFields.some((f) => f.bucket === PATCH_STRATEGY.PATCH_WRAPPER),
178
+ };
179
+ }
180
+
181
+ // Renders the FULL body of patchField() (everything between its `{`/`}`) -- one `case` per
182
+ // classified field (real codegen for an approved eligible field, an explanatory throw for
183
+ // everything else), or the untouched blanket stub when there's nothing classified at all.
184
+ // `approvedFields` is a Set of field names whose CURRENT classification the caller has already
185
+ // confirmed matches an existing approval (see lib/patch-approvals.mjs's approvedStrategyFor) --
186
+ // this function trusts that check rather than re-deriving it, keeping the "is this approval still
187
+ // valid" decision in exactly one place.
188
+ export function renderPatchFieldBody({ resourceType, dtoTypeName, patchable, updateOperation, serviceField, approvedFields, blockedReason = null }) {
189
+ if (patchable.length === 0) return blanketStub(resourceType);
190
+ const cases = patchable.map((field) => {
191
+ // The classification is real and still worth showing per-field, but if the update SERVICE
192
+ // method itself isn't safely callable with the (id, dto) shape every codegen path assumes,
193
+ // no field of this resource can be generated regardless of its own bucket -- one shared
194
+ // reason, not a per-bucket one, since the blocker isn't about any single field.
195
+ if (blockedReason) return caseThrow(field, resourceType, blockedReason);
196
+ if (!CODEGEN_ELIGIBLE.includes(field.bucket)) {
197
+ const reasonText = field.bucket === PATCH_STRATEGY.FETCH_MERGE_SUBMIT
198
+ ? 'classified as fetch-merge-submit -- this field is required (or the DTO is otherwise not partial), so patching it safely means fetching the current resource and resubmitting the full request with only this field changed. Not auto-generated (see D-patch-strategy in DECISIONS.md) -- route through the real update path by hand.'
199
+ : 'classified as unsupported (a collection, nested @Valid object, or array field) -- not safely expressible as a single scalar patch. Route through the real update path by hand.';
200
+ return caseThrow(field, resourceType, reasonText);
201
+ }
202
+ if (!approvedFields.has(field.field)) {
203
+ return caseThrow(field, resourceType, `classified as ${field.bucket} but not yet approved -- run \`bskel handles patch approve --feature <id> --resource ${resourceType} --field ${field.field} --strategy ${field.bucket} --reason "..."\` to enable codegen for this field.`);
204
+ }
205
+ return caseCodegen(field, { resourceType, dtoTypeName, updateOperation, serviceField, patchable });
206
+ }).join('\n');
207
+ return `\t\tswitch (pointer) {\n${cases}\n\t\t\tdefault -> throw new UnsupportedOperationException(\n\t\t\t\t\t"patchField not implemented for ${resourceType}" + pointer + " -- see this class's javadoc");\n\t\t}`;
208
+ }
209
+
210
+ // Entry point: classifies every component of a DTO record found in `dtoSourceText`. Returns
211
+ // `{ resourceType, fields }`, or null if no top-level `record` declaration is found (this item
212
+ // only supports record-shaped update DTOs -- the only shape found across all 17 real update DTOs
213
+ // in the oracle repo; a class-shaped update DTO is a documented gap, not silently guessed at).
214
+ export function classifyDtoFields(dtoSourceText) {
215
+ const masked = maskNonCode(dtoSourceText);
216
+ const decl = findClassOrRecordDeclaration(masked);
217
+ if (!decl || decl.keyword !== 'record') return null;
218
+
219
+ const nameEnd = masked.indexOf(decl.name, decl.index) + decl.name.length;
220
+ let i = nameEnd;
221
+ while (/\s/.test(masked[i])) i++;
222
+ if (masked[i] !== '(') return null;
223
+ const close = matchBalanced(masked, i, '(', ')');
224
+ if (close === -1) return null;
225
+
226
+ const maskedParamsText = masked.slice(i + 1, close);
227
+ const fields = splitTopLevelParams(maskedParamsText).map(classifyParam).filter(Boolean);
228
+ return { resourceType: decl.name, fields };
229
+ }