backend-skeleton 1.5.0 → 1.6.0
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 +113 -8
- package/bin/bskel.mjs +331 -53
- package/contracts/openapi.mjs +125 -18
- package/handles/providers/java-spring/ast-bridge.mjs +85 -1
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
- package/handles/providers/java-spring/emit.mjs +126 -6
- package/handles/providers/java-spring/plan.mjs +220 -74
- package/handles/providers/java-spring/source-splice.mjs +477 -0
- package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
- package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
- package/lib/attest.mjs +59 -1
- package/lib/cli.mjs +79 -7
- package/lib/doctor.mjs +23 -0
- package/lib/exit-codes.mjs +17 -0
- package/lib/gate-definitions.mjs +65 -2
- package/lib/gate-export.mjs +199 -0
- package/lib/impact-export-graphify.mjs +145 -0
- package/lib/impact-graph.mjs +194 -0
- package/lib/impact-surface.mjs +158 -0
- package/lib/impact.mjs +286 -0
- package/lib/patch-kinds.mjs +24 -0
- package/lib/repo.mjs +46 -0
- package/lib/workflow.mjs +16 -0
- package/package.json +1 -1
- package/scanners/adapters/_java-spring-analyzer.mjs +6 -0
- package/schemas/gate-attestation.schema.json +6 -1
- package/schemas/gate-export.schema.json +530 -22
- package/schemas/handles-plan.schema.json +32 -0
- package/schemas/impact-baseline.schema.json +59 -0
- package/schemas/impact-graph.schema.json +53 -0
- package/schemas/impact-report.schema.json +86 -0
- package/schemas/impact-resolution.schema.json +33 -0
- package/schemas/java-source-splice.schema.json +84 -0
- package/schemas/patch-transaction.schema.json +87 -2
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
// D-java-source-splice: the "java-source-splice" kind for lib/patch-transactions.mjs -- in-file
|
|
2
|
+
// editing of real, hand-written Java source (a method body, a field initializer, or one new
|
|
3
|
+
// import), closing D-patch-transactions' own first-named EXIT item ("in-file source splicing").
|
|
4
|
+
//
|
|
5
|
+
// Node identity (Codex's own named central risk for this class of feature -- "incorrect node
|
|
6
|
+
// identity is the central risk") is established by THREE independent mechanisms, not one:
|
|
7
|
+
// 1. the real JavaParser+Symbol Solver AST helper (ast-bridge.mjs's runAstLocate), which
|
|
8
|
+
// resolves a member by its language-guaranteed identity: a type's fully-qualified name plus,
|
|
9
|
+
// for a method, its ERASED parameter types -- the exact rule javac itself uses to forbid two
|
|
10
|
+
// same-named methods sharing the same erased signature. This is authoritative.
|
|
11
|
+
// 2. a self-validating offset conversion: the AST reports 1-based line/column, this module
|
|
12
|
+
// converts to a byte offset itself and asserts `text.slice(start,end) === regionText`
|
|
13
|
+
// exactly -- an offset-math bug fails CLOSED (refuses) rather than silently mis-splicing.
|
|
14
|
+
// 3. an INDEPENDENT falsifier using scanners/adapters/_java-spring-analyzer.mjs's maskNonCode()
|
|
15
|
+
// -- confirms the located region genuinely opens with '{' and that the member's own name
|
|
16
|
+
// appears immediately before it, on masked (comment/string-safe) text. This exists because a
|
|
17
|
+
// first draft that used ONLY the regex-based analyzer as the *locator* was proven live (see
|
|
18
|
+
// DECISIONS.md D-java-source-splice) to pick the WRONG overload -- that toolkit is kept here
|
|
19
|
+
// only as a second opinion, never as the source of truth.
|
|
20
|
+
//
|
|
21
|
+
// Mirrors stack/config-apply.mjs's/scanners/db/ddl-apply.mjs's planner contract exactly (the same
|
|
22
|
+
// seven plan fields), and reuses lib/attest.mjs-established discipline nowhere directly (this
|
|
23
|
+
// kind has no attestation involvement) but the SAME "never trust a stored computation, always
|
|
24
|
+
// re-derive" posture those items established for this codebase generally.
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { sha256String, sha256File } from '../../../lib/fsutil.mjs';
|
|
28
|
+
import { unifiedDiff } from '../../../lib/diff.mjs';
|
|
29
|
+
import { assertContained } from '../../../stack/apply.mjs';
|
|
30
|
+
import { detectAstHelperAvailable, runAstLocate, runAstParse } from './ast-bridge.mjs';
|
|
31
|
+
import { maskNonCode, matchBalanced } from '../../../scanners/adapters/_java-spring-analyzer.mjs';
|
|
32
|
+
import { detectJavaSpringRoot } from '../../../scanners/adapters/java-spring.mjs';
|
|
33
|
+
import { loadManifest, BSKEL_GENERATED_MARKER } from '../../../lib/handles-manifest.mjs';
|
|
34
|
+
import { detectBuildCommand, runBuildCheck } from '../../../lib/verify.mjs';
|
|
35
|
+
import { readBlob } from '../../../lib/patch-transactions.mjs';
|
|
36
|
+
|
|
37
|
+
export class JavaSplicePlanError extends Error {}
|
|
38
|
+
export class JavaSpliceExecutionError extends Error {}
|
|
39
|
+
|
|
40
|
+
const DESTRUCTIVE_OPS = new Set(['replace-method-body', 'insert-method-body-prologue', 'replace-field-initializer']);
|
|
41
|
+
const SUPPORTED_OPS = new Set([...DESTRUCTIVE_OPS, 'add-import']);
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------------------------
|
|
44
|
+
// Small pure helpers
|
|
45
|
+
// ---------------------------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
// 1-based line, 1-based column (JavaParser's own convention, confirmed live) -> 0-based string
|
|
48
|
+
// index. Never trusted on its own -- every caller immediately asserts the resulting slice equals
|
|
49
|
+
// the AST's own reported text (D5, mechanism 2).
|
|
50
|
+
export function lineColToOffset(text, line, col) {
|
|
51
|
+
let idx = 0;
|
|
52
|
+
let currentLine = 1;
|
|
53
|
+
while (currentLine < line) {
|
|
54
|
+
const nl = text.indexOf('\n', idx);
|
|
55
|
+
if (nl === -1) throw new JavaSplicePlanError(`internal error: line ${line} exceeds the file's actual line count`);
|
|
56
|
+
idx = nl + 1;
|
|
57
|
+
currentLine++;
|
|
58
|
+
}
|
|
59
|
+
return idx + (col - 1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function detectLineTerminator(text) {
|
|
63
|
+
const idx = text.indexOf('\n');
|
|
64
|
+
if (idx === -1) return '\n';
|
|
65
|
+
return text[idx - 1] === '\r' ? '\r\n' : '\n';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// D5, mechanism 3: an INDEPENDENT (non-AST) falsifier -- confirms the region genuinely opens a
|
|
69
|
+
// method body immediately after the member's own name, on masked (string/comment-safe) text, so
|
|
70
|
+
// a wrong-nesting-level AST result cannot silently pass. Deliberately narrow -- it is a second
|
|
71
|
+
// opinion, not a second locator.
|
|
72
|
+
export function independentFalsifierAgrees(maskedText, memberName, regionStartOffset, kind) {
|
|
73
|
+
if (kind === 'method' && maskedText[regionStartOffset] !== '{') return false;
|
|
74
|
+
const windowStart = Math.max(0, regionStartOffset - 400);
|
|
75
|
+
const window = maskedText.slice(windowStart, regionStartOffset);
|
|
76
|
+
const nameRe = new RegExp(`\\b${memberName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
|
|
77
|
+
return nameRe.test(window);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// D2: `insert-method-body-prologue`'s replacement is DERIVED, never supplied -- the original
|
|
81
|
+
// body's remainder (everything after its own leading "{") must survive as a verbatim suffix.
|
|
82
|
+
export function deriveProloguePrefix(originalRegionText, statements, terminator) {
|
|
83
|
+
if (originalRegionText === '{}' || originalRegionText.replace(/[{}\s]/g, '') === '') {
|
|
84
|
+
throw new JavaSplicePlanError('insert-method-body-prologue refuses an empty method body ("{}") -- there is no existing indentation to infer; use replace-method-body instead');
|
|
85
|
+
}
|
|
86
|
+
const lines = originalRegionText.split(terminator);
|
|
87
|
+
// lines[0] is "{" itself; the first non-blank line after it carries the indent to copy.
|
|
88
|
+
const indentLine = lines.slice(1).find((l) => l.trim().length > 0);
|
|
89
|
+
if (indentLine === undefined) {
|
|
90
|
+
throw new JavaSplicePlanError('insert-method-body-prologue could not find an indentation reference line in the existing body -- use replace-method-body instead');
|
|
91
|
+
}
|
|
92
|
+
const indent = indentLine.slice(0, indentLine.length - indentLine.trimStart().length);
|
|
93
|
+
const remainder = originalRegionText.slice(1); // drop only the leading "{"
|
|
94
|
+
const replacement = `{${terminator}${indent}${statements}${terminator}${remainder}`;
|
|
95
|
+
if (!replacement.endsWith(remainder)) {
|
|
96
|
+
throw new JavaSplicePlanError('internal error: insert-method-body-prologue built a replacement that does not end with the original body verbatim');
|
|
97
|
+
}
|
|
98
|
+
return replacement;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// D2 (add-import): the file's own "package ...; \n import ...; \n import ...;" prefix, found by
|
|
102
|
+
// plain text scanning (this edit has no per-member locator -- it targets the file's own import
|
|
103
|
+
// block, not a class member, so the AST "locate" mechanism does not apply to it). Handles the
|
|
104
|
+
// zero-existing-imports case (region ends right after the package statement, or at offset 0 for a
|
|
105
|
+
// default-package file).
|
|
106
|
+
export function findImportsRegion(text) {
|
|
107
|
+
const pkgMatch = text.match(/^\s*package\s+[\w.]+\s*;/m);
|
|
108
|
+
let end = pkgMatch ? pkgMatch.index + pkgMatch[0].length : 0;
|
|
109
|
+
const importRe = /^\s*import\s+(?:static\s+)?[\w.]+(?:\.\*)?\s*;/gm;
|
|
110
|
+
importRe.lastIndex = end;
|
|
111
|
+
let m;
|
|
112
|
+
let lastImportEnd = end;
|
|
113
|
+
while ((m = importRe.exec(text)) !== null) {
|
|
114
|
+
// Only count imports that are reasonably contiguous with what's already been scanned
|
|
115
|
+
// (allow blank lines/comments between them, but stop once we've clearly left the import
|
|
116
|
+
// block -- e.g. reached a type declaration). A simple, generous heuristic: keep advancing
|
|
117
|
+
// as long as nothing but whitespace/comments/imports appears between the previous end and
|
|
118
|
+
// this match.
|
|
119
|
+
const between = text.slice(lastImportEnd, m.index);
|
|
120
|
+
if (/\b(class|interface|enum|record|@interface)\b/.test(between)) break;
|
|
121
|
+
lastImportEnd = m.index + m[0].length;
|
|
122
|
+
}
|
|
123
|
+
return { start: 0, end: lastImportEnd };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function isDuplicateImport(text, fqn) {
|
|
127
|
+
const re = new RegExp(`^\\s*import\\s+${fqn.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*;`, 'm');
|
|
128
|
+
return re.test(text);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// D3: the Merkle roll-up -- a single hash covering every edit's own (op, locator, region_hash,
|
|
132
|
+
// signature_hash), fed into the ENGINE's one `preimage.region_hash` field so N edits are covered
|
|
133
|
+
// by the transaction engine's existing single-field TOCTOU check with zero engine change.
|
|
134
|
+
// `edits` here are the already-enriched {op, locator, region_hash, signature_hash} records (in
|
|
135
|
+
// the SAME order they were declared in the request document -- order-sensitive by design, so
|
|
136
|
+
// re-ordering two edits in a re-plan is itself detected as drift).
|
|
137
|
+
export function computeMerkleRegionHash(edits) {
|
|
138
|
+
return sha256String(JSON.stringify(
|
|
139
|
+
edits.map((e) => [e.op, e.locator ?? null, e.region_hash, e.signature_hash ?? null]),
|
|
140
|
+
));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Rung 13: throws JavaSplicePlanError naming the file if any two [start,end) ranges overlap.
|
|
144
|
+
// `ranges` need not be pre-sorted.
|
|
145
|
+
export function assertNoOverlaps(ranges, file) {
|
|
146
|
+
const sorted = [...ranges].sort((a, b) => a.start - b.start);
|
|
147
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
148
|
+
if (sorted[i].start < sorted[i - 1].end) {
|
|
149
|
+
throw new JavaSplicePlanError(`two edits target overlapping regions of "${file}" -- refusing`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Applies `edits` (each carrying `_start`/`_end`/`_finalReplacement`) to `text` from the END of
|
|
155
|
+
// the file backward (highest start offset first) -- every earlier (lower-start) edit's own
|
|
156
|
+
// offsets stay valid throughout, since nothing before the current edit's start has been touched
|
|
157
|
+
// yet. `add-import`'s region always starts at/near offset 0, so it is always applied LAST here.
|
|
158
|
+
export function applyEditsDescending(text, edits) {
|
|
159
|
+
const descending = [...edits].sort((a, b) => b._start - a._start);
|
|
160
|
+
let rendered = text;
|
|
161
|
+
for (const e of descending) {
|
|
162
|
+
rendered = rendered.slice(0, e._start) + e._finalReplacement + rendered.slice(e._end);
|
|
163
|
+
}
|
|
164
|
+
return rendered;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The SAME splice, built the opposite direction (lowest start offset first, single forward pass)
|
|
168
|
+
// -- an independent second construction, not a refactor of the first. Asserted equal to
|
|
169
|
+
// applyEditsDescending()'s own result by the caller (assertOnlyRegionsChanged's actual mechanism)
|
|
170
|
+
// so an off-by-one in EITHER loop is caught, not just a stray change outside the touched regions.
|
|
171
|
+
export function applyEditsAscending(text, edits) {
|
|
172
|
+
const ascending = [...edits].sort((a, b) => a._start - b._start);
|
|
173
|
+
let result = '';
|
|
174
|
+
let cursor = 0;
|
|
175
|
+
for (const e of ascending) {
|
|
176
|
+
result += text.slice(cursor, e._start) + e._finalReplacement;
|
|
177
|
+
cursor = e._end;
|
|
178
|
+
}
|
|
179
|
+
result += text.slice(cursor);
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// D4's defensive assertion, named for what it proves: the only bytes that differ between
|
|
184
|
+
// `original` and `rendered` are inside the edits' own regions. Implemented by cross-checking two
|
|
185
|
+
// INDEPENDENTLY-constructed renders (forward vs backward splice) rather than diffing remainders --
|
|
186
|
+
// strictly stronger, since it also catches an off-by-one inside either construction itself, not
|
|
187
|
+
// only a stray change outside every touched region. Should be unreachable; throws if it isn't.
|
|
188
|
+
export function assertOnlyRegionsChanged(text, edits) {
|
|
189
|
+
const descending = applyEditsDescending(text, edits);
|
|
190
|
+
const ascending = applyEditsAscending(text, edits);
|
|
191
|
+
if (descending !== ascending) {
|
|
192
|
+
throw new JavaSplicePlanError('internal error: forward and backward edit application produced different results -- refusing rather than risk a corrupted splice (this should be unreachable; please report it)');
|
|
193
|
+
}
|
|
194
|
+
return descending;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------------------------
|
|
198
|
+
// Planner
|
|
199
|
+
// ---------------------------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
// `params` = { file (repo-relative), edits (the --splice-file document's own `edits[]`, already
|
|
202
|
+
// schema-validated by the caller at propose time -- re-plan at approve/apply reconstructs this
|
|
203
|
+
// same shape from the stored transaction's target.edits, see paramsFromTxn() in lib/patch-kinds.mjs) }.
|
|
204
|
+
export async function planJavaSourceSplice(repoRoot, { file, edits }) {
|
|
205
|
+
if (!Array.isArray(edits) || edits.length === 0) {
|
|
206
|
+
throw new JavaSplicePlanError('java-source-splice requires at least one edit');
|
|
207
|
+
}
|
|
208
|
+
for (const e of edits) {
|
|
209
|
+
if (!SUPPORTED_OPS.has(e.op)) {
|
|
210
|
+
throw new JavaSplicePlanError(`unsupported op "${e.op}" -- java-source-splice only supports: ${[...SUPPORTED_OPS].join(', ')}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const addImportEdits = edits.filter((e) => e.op === 'add-import');
|
|
214
|
+
if (addImportEdits.length > 1) {
|
|
215
|
+
throw new JavaSplicePlanError('at most one add-import edit is permitted per transaction (each would target the same file-prefix region, so a second one would overlap the first)');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const targetAbs = path.join(repoRoot, file);
|
|
219
|
+
assertContained(repoRoot, targetAbs, 'java-source-splice target file');
|
|
220
|
+
if (!file.endsWith('.java')) {
|
|
221
|
+
throw new JavaSplicePlanError(`"${file}" is not a .java file`);
|
|
222
|
+
}
|
|
223
|
+
if (!fs.existsSync(targetAbs)) {
|
|
224
|
+
throw new JavaSplicePlanError(`"${file}" does not exist -- nothing to splice`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const text = fs.readFileSync(targetAbs, 'utf8');
|
|
228
|
+
|
|
229
|
+
// Rung: bskel-generated files are off-limits -- a later `handles emit` would silently
|
|
230
|
+
// overwrite a hand-spliced edit; the real fix is to edit the template or the resolver-
|
|
231
|
+
// ownership path instead, not to splice generated output.
|
|
232
|
+
const manifest = loadManifest(repoRoot);
|
|
233
|
+
const relForManifest = path.relative(repoRoot, targetAbs).split(path.sep).join('/');
|
|
234
|
+
if (text.includes(BSKEL_GENERATED_MARKER) || Object.hasOwn(manifest.files, relForManifest)) {
|
|
235
|
+
throw new JavaSplicePlanError(`"${file}" is bskel-generated (carries the "${BSKEL_GENERATED_MARKER}" marker, or is tracked in .sbf/handles-manifest.json) -- a later \`handles emit\` would silently overwrite a splice here; edit the template or the resolver-ownership path instead`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const lineTerminator = detectLineTerminator(text);
|
|
239
|
+
if (lineTerminator === '\r\n') {
|
|
240
|
+
throw new JavaSplicePlanError(`"${file}" uses CRLF line endings -- refusing rather than risk mixed line endings after a splice. Convert the file to LF first.`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const detection = detectAstHelperAvailable();
|
|
244
|
+
if (!detection.available) {
|
|
245
|
+
throw new JavaSplicePlanError(detection.reason);
|
|
246
|
+
}
|
|
247
|
+
const build = detectBuildCommand(repoRoot);
|
|
248
|
+
if (!build) {
|
|
249
|
+
throw new JavaSplicePlanError('no recognized build tool (gradlew/pom.xml/package.json) found -- a postcondition that could never run is not a postcondition');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const srcRoot = detectJavaSpringRoot(repoRoot);
|
|
253
|
+
if (!srcRoot) {
|
|
254
|
+
throw new JavaSplicePlanError(`could not find a Java "src/main/java" root above "${file}" (no build.gradle/pom.xml with a sibling src/main/java) -- java-source-splice only supports the standard Maven/Gradle layout`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const locators = edits
|
|
258
|
+
.filter((e) => e.op !== 'add-import')
|
|
259
|
+
.map((e) => ({
|
|
260
|
+
type_fqn: e.locator.type_fqn,
|
|
261
|
+
member_kind: e.locator.member_kind,
|
|
262
|
+
member_name: e.locator.member_name,
|
|
263
|
+
erased_param_types: e.locator.erased_param_types ?? [],
|
|
264
|
+
}));
|
|
265
|
+
|
|
266
|
+
let astResult = { topLevelTypes: [], results: [] };
|
|
267
|
+
if (locators.length > 0) {
|
|
268
|
+
astResult = await runAstLocate(targetAbs, srcRoot, locators);
|
|
269
|
+
if (astResult.topLevelTypes.length !== 1) {
|
|
270
|
+
throw new JavaSplicePlanError(`"${file}" declares ${astResult.topLevelTypes.length} top-level type(s) (${astResult.topLevelTypes.join(', ') || 'none'}) -- java-source-splice V1 only supports a file with exactly one top-level type`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const masked = maskNonCode(text);
|
|
275
|
+
let locatorResultIndex = 0;
|
|
276
|
+
const enrichedEdits = [];
|
|
277
|
+
const occupiedRanges = []; // {start, end} in ORIGINAL text, for overlap detection
|
|
278
|
+
|
|
279
|
+
for (const e of edits) {
|
|
280
|
+
if (e.op === 'add-import') {
|
|
281
|
+
const region = findImportsRegion(text);
|
|
282
|
+
const originalRegionText = text.slice(region.start, region.end);
|
|
283
|
+
const fqn = e.imports[0];
|
|
284
|
+
if (isDuplicateImport(text, fqn)) {
|
|
285
|
+
throw new JavaSplicePlanError(`"${file}" already imports "${fqn}" -- nothing to add`);
|
|
286
|
+
}
|
|
287
|
+
const finalReplacement = `${originalRegionText}${originalRegionText ? lineTerminator : ''}import ${fqn};`;
|
|
288
|
+
if (!finalReplacement.startsWith(originalRegionText)) {
|
|
289
|
+
throw new JavaSplicePlanError('internal error: add-import built a replacement that does not start with the existing imports verbatim');
|
|
290
|
+
}
|
|
291
|
+
occupiedRanges.push({ start: region.start, end: region.end });
|
|
292
|
+
enrichedEdits.push({
|
|
293
|
+
op: e.op,
|
|
294
|
+
imports: e.imports,
|
|
295
|
+
region_hash: sha256String(originalRegionText),
|
|
296
|
+
signature_hash: null,
|
|
297
|
+
located: { start: region.start, end: region.end, begin_line: null, end_line: null },
|
|
298
|
+
_start: region.start,
|
|
299
|
+
_end: region.end,
|
|
300
|
+
_finalReplacement: finalReplacement,
|
|
301
|
+
});
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const result = astResult.results[locatorResultIndex++];
|
|
306
|
+
if (!result || !result.resolved) {
|
|
307
|
+
throw new JavaSplicePlanError(`could not locate ${e.locator.member_kind} "${e.locator.member_name}" in "${e.locator.type_fqn}": ${result?.error ?? 'no result returned by the AST helper'}`);
|
|
308
|
+
}
|
|
309
|
+
if ((result.unresolvedParamTypes ?? []).length > 0) {
|
|
310
|
+
throw new JavaSplicePlanError(`"${e.locator.member_name}" in "${e.locator.type_fqn}" has unresolvable parameter type(s) (${result.unresolvedParamTypes.join(', ')}) -- refusing to splice a member whose own signature this tool cannot fully resolve`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const regionStart = lineColToOffset(text, result.beginLine, result.beginColumn);
|
|
314
|
+
const regionEnd = lineColToOffset(text, result.endLine, result.endColumn) + 1; // AST end is inclusive of the last char
|
|
315
|
+
const regionText = text.slice(regionStart, regionEnd);
|
|
316
|
+
if (regionText !== result.regionText) {
|
|
317
|
+
throw new JavaSplicePlanError(`internal error: offset-converted region text for "${e.locator.member_name}" does not match the AST helper's own reported text -- refusing rather than risk mis-splicing (this should be unreachable; please report it)`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const sigStart = lineColToOffset(text, result.signatureBeginLine, result.signatureBeginColumn);
|
|
321
|
+
const sigEnd = lineColToOffset(text, result.signatureEndLine, result.signatureEndColumn) + 1;
|
|
322
|
+
const signatureText = text.slice(sigStart, sigEnd);
|
|
323
|
+
if (signatureText !== result.signatureText) {
|
|
324
|
+
throw new JavaSplicePlanError(`internal error: offset-converted signature text for "${e.locator.member_name}" does not match the AST helper's own reported text -- refusing rather than risk mis-splicing (this should be unreachable; please report it)`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const kind = e.locator.member_kind === 'field' ? 'field' : 'method';
|
|
328
|
+
if (!independentFalsifierAgrees(masked, e.locator.member_name, regionStart, kind)) {
|
|
329
|
+
throw new JavaSplicePlanError(`the independent (non-AST) locator check disagrees with the AST helper for "${e.locator.member_name}" in "${e.locator.type_fqn}" -- refusing rather than trust a single mechanism for something this consequential`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let finalReplacement;
|
|
333
|
+
if (e.op === 'replace-method-body') {
|
|
334
|
+
const candidate = e.replacement;
|
|
335
|
+
if (!candidate.startsWith('{') || !candidate.trimEnd().endsWith('}')) {
|
|
336
|
+
throw new JavaSplicePlanError(`replace-method-body's replacement for "${e.locator.member_name}" must start with "{" and end with "}"`);
|
|
337
|
+
}
|
|
338
|
+
if (candidate.includes('\r\n')) {
|
|
339
|
+
throw new JavaSplicePlanError(`replace-method-body's replacement for "${e.locator.member_name}" contains CRLF line endings, but "${file}" uses LF -- refusing to mix line endings`);
|
|
340
|
+
}
|
|
341
|
+
const closeIdx = matchBalanced(maskNonCode(candidate), 0, '{', '}');
|
|
342
|
+
if (closeIdx !== candidate.trimEnd().length - 1) {
|
|
343
|
+
throw new JavaSplicePlanError(`replace-method-body's replacement for "${e.locator.member_name}" is not brace-balanced`);
|
|
344
|
+
}
|
|
345
|
+
finalReplacement = candidate;
|
|
346
|
+
} else if (e.op === 'insert-method-body-prologue') {
|
|
347
|
+
finalReplacement = deriveProloguePrefix(regionText, e.statements, lineTerminator);
|
|
348
|
+
} else if (e.op === 'replace-field-initializer') {
|
|
349
|
+
finalReplacement = e.replacement;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
occupiedRanges.push({ start: regionStart, end: regionEnd });
|
|
353
|
+
enrichedEdits.push({
|
|
354
|
+
op: e.op,
|
|
355
|
+
locator: e.locator,
|
|
356
|
+
...(e.op !== 'insert-method-body-prologue' ? { replacement: e.replacement } : { statements: e.statements }),
|
|
357
|
+
region_hash: sha256String(regionText),
|
|
358
|
+
signature_hash: sha256String(signatureText),
|
|
359
|
+
located: { start: regionStart, end: regionEnd, begin_line: result.beginLine, end_line: result.endLine },
|
|
360
|
+
_start: regionStart,
|
|
361
|
+
_end: regionEnd,
|
|
362
|
+
_finalReplacement: finalReplacement,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
assertNoOverlaps(occupiedRanges, file);
|
|
367
|
+
const rendered = assertOnlyRegionsChanged(text, enrichedEdits);
|
|
368
|
+
|
|
369
|
+
const parseResult = await runAstParse(rendered);
|
|
370
|
+
if (!parseResult.ok) {
|
|
371
|
+
throw new JavaSplicePlanError(`the proposed splice does not parse as valid Java:\n${(parseResult.problems ?? []).join('\n')}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const regionHash = computeMerkleRegionHash(enrichedEdits);
|
|
375
|
+
|
|
376
|
+
const publicEdits = enrichedEdits.map(({ _start, _end, _finalReplacement, ...rest }) => rest);
|
|
377
|
+
const memberSummary = publicEdits
|
|
378
|
+
.map((e) => (e.locator ? `${e.locator.member_name}` : `import ${e.imports[0]}`))
|
|
379
|
+
.join(', ');
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
target: {
|
|
383
|
+
file,
|
|
384
|
+
source_root: path.relative(repoRoot, srcRoot).split(path.sep).join('/'),
|
|
385
|
+
line_terminator: lineTerminator,
|
|
386
|
+
edits: publicEdits,
|
|
387
|
+
},
|
|
388
|
+
preimage: { region_hash: regionHash, file_hash: sha256String(text) },
|
|
389
|
+
current_value: `${publicEdits.length} edit(s) to ${memberSummary}`,
|
|
390
|
+
proposed_value: unifiedDiff(file, text, rendered),
|
|
391
|
+
postcondition: { kind: 'java-compiles', build_tool: build.tool, build_command: `${build.cmd} ${build.args.join(' ')}` },
|
|
392
|
+
originalContent: text,
|
|
393
|
+
renderedContent: rendered,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------------------------
|
|
398
|
+
// Executors
|
|
399
|
+
// ---------------------------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
// D6: write, then compile -- the filesystem transposition of ddl-apply's real Postgres
|
|
402
|
+
// BEGIN/COMMIT/ROLLBACK. A compile failure restores the ORIGINAL bytes from the CAS blob before
|
|
403
|
+
// throwing -- the transaction record itself stays "approved", not "applied", so a retry (a fresh
|
|
404
|
+
// propose against the still-unsplice-broken file) is the natural next step.
|
|
405
|
+
export async function executeJavaSpliceApply(root, featureId, txn, freshKindPlan) {
|
|
406
|
+
const targetAbs = path.join(root, txn.target.file);
|
|
407
|
+
fs.writeFileSync(targetAbs, freshKindPlan.renderedContent);
|
|
408
|
+
const buildResult = runBuildCheck(root);
|
|
409
|
+
if (!buildResult.ok) {
|
|
410
|
+
const original = readBlob(root, featureId, txn.preimage.file_hash);
|
|
411
|
+
fs.writeFileSync(targetAbs, original);
|
|
412
|
+
throw new JavaSpliceExecutionError(
|
|
413
|
+
`the splice was written but the project failed to compile -- "${txn.target.file}" has been restored to its original content.\n\n${buildResult.message}`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return { postimage_file_hash: sha256String(freshKindPlan.renderedContent), build_tool: buildResult.tool };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// D7: byte-exact whole-file restore from the CAS blob, mirroring executeConfigRollback() exactly,
|
|
420
|
+
// plus a compile check afterward (recorded, not enforced -- restoring a KNOWN-GOOD prior state
|
|
421
|
+
// must never be refused just because something ELSE in the repo is currently broken).
|
|
422
|
+
export async function executeJavaSpliceRollback(root, featureId, txn, { force = false } = {}) {
|
|
423
|
+
const targetAbs = path.join(root, txn.target.file);
|
|
424
|
+
const currentHash = sha256File(targetAbs);
|
|
425
|
+
if (currentHash !== txn.apply.postimage_file_hash && !force) {
|
|
426
|
+
throw new JavaSpliceExecutionError(
|
|
427
|
+
`"${txn.target.file}" has changed since transaction "${txn.transaction_id}" applied -- rolling back would silently clobber that change; pass --force --reason if intentional`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
const original = readBlob(root, featureId, txn.preimage.file_hash);
|
|
431
|
+
fs.writeFileSync(targetAbs, original);
|
|
432
|
+
const buildResult = runBuildCheck(root);
|
|
433
|
+
return { compile_after_rollback: buildResult.ok ? 'ok' : 'failed' };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Retyping which hand-written member(s) are being rewritten is the actual attention check --
|
|
437
|
+
// an add-import-only transaction is purely additive, so it falls back to the transaction id
|
|
438
|
+
// (ddl-apply's own non-destructive fallback, same reasoning).
|
|
439
|
+
export function requiredConfirmValue(txn) {
|
|
440
|
+
const destructive = txn.target.edits.filter((e) => DESTRUCTIVE_OPS.has(e.op));
|
|
441
|
+
if (destructive.length === 0) return txn.transaction_id;
|
|
442
|
+
const members = destructive.map((e) => `${e.locator.type_fqn.split('.').pop()}#${e.locator.member_name}`);
|
|
443
|
+
return [...new Set(members)].sort().join(',');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Consulted by bin/bskel.mjs's cmdPatchApprove/cmdPatchApply only when replanTransaction() throws
|
|
447
|
+
// StaleTransactionError -- names which edit's stored hash no longer matches the fresh one, and
|
|
448
|
+
// which of region_hash/signature_hash moved, plus a whole-file diff against the fresh content
|
|
449
|
+
// recovered from the original preimage blob.
|
|
450
|
+
export function describeStaleness(root, txn, freshPlan) {
|
|
451
|
+
const lines = [];
|
|
452
|
+
const stored = txn.target.edits;
|
|
453
|
+
const fresh = freshPlan.target.edits;
|
|
454
|
+
for (let i = 0; i < Math.max(stored.length, fresh.length); i++) {
|
|
455
|
+
const s = stored[i];
|
|
456
|
+
const f = fresh[i];
|
|
457
|
+
if (!s || !f) {
|
|
458
|
+
lines.push(` edit ${i}: edit list shape changed`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const what = s.locator ? `${s.locator.type_fqn}#${s.locator.member_name}` : `add-import ${s.imports?.[0]}`;
|
|
462
|
+
if (s.region_hash !== f.region_hash) lines.push(` edit ${i} (${what}): region content changed`);
|
|
463
|
+
if (s.signature_hash !== f.signature_hash) lines.push(` edit ${i} (${what}): signature changed (e.g. return type, modifiers)`);
|
|
464
|
+
}
|
|
465
|
+
let diffText = '';
|
|
466
|
+
try {
|
|
467
|
+
const original = readBlob(root, txn.feature_id, txn.preimage.file_hash);
|
|
468
|
+
const currentAbs = path.join(root, txn.target.file);
|
|
469
|
+
const current = fs.readFileSync(currentAbs, 'utf8');
|
|
470
|
+
diffText = unifiedDiff(txn.target.file, original, current);
|
|
471
|
+
} catch {
|
|
472
|
+
// best-effort diagnostics only
|
|
473
|
+
}
|
|
474
|
+
return [`transaction "${txn.transaction_id}"'s target has changed since it was proposed:`, ...lines, diffText]
|
|
475
|
+
.filter(Boolean)
|
|
476
|
+
.join('\n');
|
|
477
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.domain.{{MODULE}}.infrastructure;
|
|
2
|
+
|
|
3
|
+
import org.springframework.security.core.Authentication;
|
|
4
|
+
|
|
5
|
+
import java.util.UUID;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generated by backend-skeleton ({@code bskel handles emit}) for feature {{FEATURE_ID}}.
|
|
9
|
+
*
|
|
10
|
+
* <p>D-resolver-policy-contract: {@code bskel} could not safely auto-derive a role-only
|
|
11
|
+
* authorization policy for {{RESOURCE_TYPE}} -- see the evidence below. <strong>Your application
|
|
12
|
+
* will not start until a {@code @Component} implementing this interface exists</strong> ({@link
|
|
13
|
+
* {{RESOURCE_TYPE}}Resolver} declares it as a required constructor argument, so Spring cannot
|
|
14
|
+
* construct that bean -- and therefore cannot map the {{RESOURCE_TYPE}} handle routes at all --
|
|
15
|
+
* without one). Return {@code false} to deny; a genuinely-public resource's escape hatch is
|
|
16
|
+
* {@code return true;} -- explicit, greppable, and reviewable, unlike a silently-materialized role
|
|
17
|
+
* check that happens to be wrong.
|
|
18
|
+
*
|
|
19
|
+
{{POLICY_EVIDENCE_JAVADOC}} */
|
|
20
|
+
public interface {{RESOURCE_TYPE}}AuthorizationPolicy {
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param action exactly one of {@code "fetch"}, {@code "patch"}, {@code "recover"}
|
|
24
|
+
* @param resourceUid the resource's own UUID, decoded from the handle
|
|
25
|
+
* @param pointer non-null only for a field-level (kind=f) handle -- see {@link
|
|
26
|
+
* {{RESOURCE_TYPE}}Resolver}'s sibling {@code ResourceResolver#fetch} javadoc
|
|
27
|
+
* @return {@code false} to deny (a 403 is raised); {@code true} to allow
|
|
28
|
+
*/
|
|
29
|
+
boolean authorize(Authentication authentication, String action, UUID resourceUid, String pointer);
|
|
30
|
+
}
|
|
@@ -60,7 +60,7 @@ public class HandleController {
|
|
|
60
60
|
HandleCodec.Decoded decoded = decodeOrThrow(handle);
|
|
61
61
|
ResourceResolver resolver = resolverFor(decoded.type());
|
|
62
62
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
|
63
|
-
|
|
63
|
+
authorizeOrThrow(resolver, authentication, "fetch", resolver.requiredAuthority(), decoded.uuid(), decoded.pointer());
|
|
64
64
|
if (ENFORCE_REGISTRY) {
|
|
65
65
|
requireRegisteredOrThrow(decoded);
|
|
66
66
|
}
|
|
@@ -101,7 +101,7 @@ public class HandleController {
|
|
|
101
101
|
// O5 (D-resolver-authorization-action-aware): the PATCH-specific authority, independently
|
|
102
102
|
// derived from the entity's own UPDATE endpoint -- NOT resolver.requiredAuthority(), which
|
|
103
103
|
// is fetch()/recover()'s own value and may legitimately require a different role.
|
|
104
|
-
|
|
104
|
+
authorizeOrThrow(resolver, authentication, "patch", resolver.requiredAuthorityForPatch(), decoded.uuid(), decoded.pointer());
|
|
105
105
|
if (ENFORCE_REGISTRY) {
|
|
106
106
|
requireRegisteredOrThrow(decoded);
|
|
107
107
|
}
|
|
@@ -114,7 +114,8 @@ public class HandleController {
|
|
|
114
114
|
public ResponseEntity<?> recover(@PathVariable String handle, @RequestParam(required = false) Instant at) {
|
|
115
115
|
HandleCodec.Decoded decoded = decodeOrThrow(handle);
|
|
116
116
|
ResourceResolver resolver = resolverFor(decoded.type());
|
|
117
|
-
|
|
117
|
+
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
|
118
|
+
authorizeOrThrow(resolver, authentication, "recover", resolver.requiredAuthority(), decoded.uuid(), decoded.pointer());
|
|
118
119
|
|
|
119
120
|
// D-security-9 / O3 (D-handle-registry-enforcement): recover() structurally REQUIRES a
|
|
120
121
|
// registry row to find a snapshot's own primary key -- there is no "unenforced" mode for
|
|
@@ -218,4 +219,21 @@ public class HandleController {
|
|
|
218
219
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "requires authority " + requiredAuthority);
|
|
219
220
|
}
|
|
220
221
|
}
|
|
222
|
+
|
|
223
|
+
// D-resolver-policy-contract (PC4): the single call site fetch/patch/recover all route through.
|
|
224
|
+
// "role" mode (the default -- resolver.authorizationMode() unoverridden) keeps the historical
|
|
225
|
+
// requireAuthority() equality check, then still calls resolver.authorize() (a no-op default
|
|
226
|
+
// returning true). "delegated" mode SKIPS requireAuthority() entirely and resolver.authorize()
|
|
227
|
+
// -- backed by an injected AuthorizationPolicy bean for an unresolved resource, see
|
|
228
|
+
// ResourceResolverStub.java.tmpl -- is the whole gate. `action` is exactly "fetch"/"patch"/
|
|
229
|
+
// "recover"; `resourceUid`/`pointer` let a delegated policy make a field-level distinction
|
|
230
|
+
// fetch()/patch() themselves already make.
|
|
231
|
+
private void authorizeOrThrow(ResourceResolver resolver, Authentication authentication, String action, String requiredAuthority, UUID resourceUid, String pointer) {
|
|
232
|
+
if (!"delegated".equals(resolver.authorizationMode())) {
|
|
233
|
+
requireAuthority(authentication, requiredAuthority);
|
|
234
|
+
}
|
|
235
|
+
if (!resolver.authorize(authentication, action, resourceUid, pointer)) {
|
|
236
|
+
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "authorization policy denied " + action + " on " + resolver.type());
|
|
237
|
+
}
|
|
238
|
+
}
|
|
221
239
|
}
|
|
@@ -90,4 +90,30 @@ public interface ResourceResolver {
|
|
|
90
90
|
|
|
91
91
|
/** O4 (D-handle-lifecycle): the feature_uid this resolver was generated for, baked in the same way as {@link #contractRef()}. */
|
|
92
92
|
UUID featureUid();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* D-resolver-policy-contract (PC4): {@code "role"} (the default) means {@link HandleController}
|
|
96
|
+
* performs its historical {@code requireAuthority()}/{@code requiredAuthorityForPatch()}
|
|
97
|
+
* equality check before calling {@link #authorize}. {@code "delegated"} means it SKIPS that
|
|
98
|
+
* check entirely and {@link #authorize} is the whole gate. A {@code default} method (not
|
|
99
|
+
* abstract) so every resolver implemented before this existed keeps compiling and behaving
|
|
100
|
+
* identically -- overriding it is opt-in, never required.
|
|
101
|
+
*/
|
|
102
|
+
default String authorizationMode() {
|
|
103
|
+
return "role";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* D-resolver-policy-contract (PC4): a policy hook alongside the role check above.
|
|
108
|
+
* {@code action} is exactly one of {@code "fetch"}, {@code "patch"}, {@code "recover"}. Return
|
|
109
|
+
* {@code false} to deny (a 403 is raised); the {@code default} body returns {@code true} --
|
|
110
|
+
* the historical no-op, since in {@code "role"} mode {@link #requiredAuthority()}/
|
|
111
|
+
* {@link #requiredAuthorityForPatch()} already did the real check. A hand-written resolver
|
|
112
|
+
* that needs ownership/tenant scoping beyond a single role string overrides this (and usually
|
|
113
|
+
* also {@link #authorizationMode()} to return {@code "delegated"}), using {@code authentication}
|
|
114
|
+
* the same way {@link #fetch}'s own javadoc already describes.
|
|
115
|
+
*/
|
|
116
|
+
default boolean authorize(Authentication authentication, String action, UUID resourceUid, String pointer) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
93
119
|
}
|
|
@@ -51,4 +51,13 @@ final class {{RESOURCE_TYPE}}ResolverPolicy {
|
|
|
51
51
|
static UUID featureUid() {
|
|
52
52
|
return FEATURE_UID;
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
// D-resolver-policy-contract (PC2): always regenerated (this file is always safe to
|
|
56
|
+
// regenerate), unconditionally -- "role" for a materialized resource, "delegated" for an
|
|
57
|
+
// unresolved one. {{RESOURCE_TYPE}}Resolver only overrides ResourceResolver#authorizationMode()
|
|
58
|
+
// to delegate here when delegated; a materialized resolver relies on the interface's own
|
|
59
|
+
// default ("role"), so this value is inspectable even though nothing calls it in that case.
|
|
60
|
+
static String authorizationMode() {
|
|
61
|
+
return "{{AUTHORIZATION_MODE}}";
|
|
62
|
+
}
|
|
54
63
|
}
|
|
@@ -2,7 +2,7 @@ package {{BASE_PACKAGE}}.domain.{{MODULE}}.infrastructure;
|
|
|
2
2
|
|
|
3
3
|
import {{BASE_PACKAGE}}.global.handle.ResourceResolver;
|
|
4
4
|
import {{SERVICE_IMPORT}};
|
|
5
|
-
{{PATCH_IMPORTS}}import lombok.RequiredArgsConstructor;
|
|
5
|
+
{{PATCH_IMPORTS}}{{POLICY_IMPORT}}import lombok.RequiredArgsConstructor;
|
|
6
6
|
import org.springframework.security.core.Authentication;
|
|
7
7
|
import org.springframework.stereotype.Component;
|
|
8
8
|
|
|
@@ -38,7 +38,7 @@ import java.util.UUID;
|
|
|
38
38
|
public class {{RESOURCE_TYPE}}Resolver implements ResourceResolver {
|
|
39
39
|
|
|
40
40
|
private final {{SERVICE_TYPE}} {{SERVICE_FIELD}};
|
|
41
|
-
{{PATCH_FIELDS}}
|
|
41
|
+
{{PATCH_FIELDS}}{{POLICY_FIELD}}
|
|
42
42
|
// D-resolver-policy-split: these five delegate to {{RESOURCE_TYPE}}ResolverPolicy (a separate
|
|
43
43
|
// generated file) rather than implementing the values directly -- see that class's own
|
|
44
44
|
// javadoc for why. Their TEXT never changes when the underlying value changes, which is what
|
|
@@ -83,4 +83,4 @@ public class {{RESOURCE_TYPE}}Resolver implements ResourceResolver {
|
|
|
83
83
|
public UUID featureUid() {
|
|
84
84
|
return {{RESOURCE_TYPE}}ResolverPolicy.featureUid();
|
|
85
85
|
}
|
|
86
|
-
}
|
|
86
|
+
{{POLICY_OVERRIDES}}}
|