Skip to content

Commit 0e43967

Browse files
CopilotEMaher
authored andcommitted
fix: expand transitive named value chains and iterative token normalization
1 parent e05c8d3 commit 0e43967

5 files changed

Lines changed: 288 additions & 31 deletions

File tree

src/services/extract-service.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,7 @@ async function resolveAndExtractTransitive(
473473

474474
// Build maps for transitive resolution
475475
const apiJsonMap = new Map<string, Record<string, unknown>>();
476+
const namedValueJsonMap = new Map<string, Record<string, unknown>>();
476477
for (const apiResult of result.apiResults) {
477478
// Find the API's JSON from type results
478479
const apiTypeResults = result.typeResults.filter((r) => r.type === ResourceType.Api);
@@ -485,10 +486,21 @@ async function resolveAndExtractTransitive(
485486
}
486487
}
487488

489+
const namedValueTypeResults = result.typeResults.filter((r) => r.type === ResourceType.NamedValue);
490+
for (const ntr of namedValueTypeResults) {
491+
for (const extracted of ntr.extracted) {
492+
if (extracted.status === 'success') {
493+
const name = getNamePart(extracted.descriptor.nameParts, 0);
494+
namedValueJsonMap.set(name, extracted.json);
495+
}
496+
}
497+
}
498+
488499
// Find transitive dependencies
489500
const transitiveDeps = findTransitiveDependencies(
490501
result.collectedPolicies,
491-
apiJsonMap
502+
apiJsonMap,
503+
namedValueJsonMap
492504
);
493505

494506
// Filter out already-extracted resources

src/services/resource-publisher.ts

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -554,31 +554,67 @@ async function normalizeNamedValueReferences(
554554
return json;
555555
}
556556

557-
// Recursively normalize all {{named-value-token}} references to canonical names.
558-
// APIM resource shapes vary widely (strings, nested objects, arrays), so traverse
559-
// the entire payload structure.
560-
const normalizeValue = (value: unknown): unknown => {
561-
if (typeof value === 'string') {
562-
return value.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, tokenName: string) => {
563-
const canonicalName = namedValueNames.get(tokenName.toLowerCase());
564-
return canonicalName ? `{{${canonicalName}}}` : match;
565-
});
557+
const normalizeTokenString = (value: string): string =>
558+
value.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, tokenName: string) => {
559+
const canonicalName = namedValueNames.get(tokenName.toLowerCase());
560+
return canonicalName ? `{{${canonicalName}}}` : match;
561+
});
562+
563+
const normalizedRoot: Record<string, unknown> = {};
564+
const stack: Array<{ source: unknown; assign: (value: unknown) => void }> = [];
565+
566+
for (const [key, value] of Object.entries(json)) {
567+
stack.push({
568+
source: value,
569+
assign: (normalizedValue: unknown) => {
570+
normalizedRoot[key] = normalizedValue;
571+
},
572+
});
573+
}
574+
575+
while (stack.length > 0) {
576+
const frame = stack.pop();
577+
if (!frame) {
578+
continue;
566579
}
567580

568-
if (Array.isArray(value)) {
569-
return value.map(normalizeValue);
581+
const { source, assign } = frame;
582+
583+
if (typeof source === 'string') {
584+
assign(normalizeTokenString(source));
585+
continue;
570586
}
571587

572-
if (value && typeof value === 'object') {
573-
const normalized: Record<string, unknown> = {};
574-
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
575-
normalized[key] = normalizeValue(child);
588+
if (Array.isArray(source)) {
589+
const out: unknown[] = new Array(source.length);
590+
assign(out);
591+
for (let i = source.length - 1; i >= 0; i--) {
592+
stack.push({
593+
source: source[i],
594+
assign: (normalizedValue: unknown) => {
595+
out[i] = normalizedValue;
596+
},
597+
});
576598
}
577-
return normalized;
599+
continue;
578600
}
579601

580-
return value;
581-
};
602+
if (source && typeof source === 'object') {
603+
const out: Record<string, unknown> = {};
604+
assign(out);
605+
for (const [key, child] of Object.entries(source as Record<string, unknown>)) {
606+
stack.push({
607+
source: child,
608+
assign: (normalizedValue: unknown) => {
609+
out[key] = normalizedValue;
610+
},
611+
});
612+
}
613+
continue;
614+
}
615+
616+
assign(source);
617+
}
582618

583-
return normalizeValue(json) as Record<string, unknown>;
619+
return normalizedRoot;
584620
}

src/services/transitive-resolver.ts

Lines changed: 103 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ export function scanApiVersionSetReference(
113113
export function resolveTransitiveDependencies(
114114
extractedPolicies: Map<string, string>,
115115
extractedApis: Map<string, Record<string, unknown>>,
116-
currentFilter: FilterConfig
116+
currentFilter: FilterConfig,
117+
extractedNamedValues: Map<string, Record<string, unknown>> = new Map()
117118
): FilterConfig {
118119
const expanded = { ...currentFilter };
119120
let changed = true;
@@ -142,6 +143,23 @@ export function resolveTransitiveDependencies(
142143
changed = true;
143144
}
144145
}
146+
147+
// Scan extracted named values for references to other named values.
148+
for (const [name, namedValueJson] of extractedNamedValues) {
149+
const currentNamedValues = expanded.namedValueNames;
150+
if (currentNamedValues !== undefined) {
151+
const isIncluded = currentNamedValues.some((n) => n.toLowerCase() === name.toLowerCase());
152+
if (!isIncluded) {
153+
continue;
154+
}
155+
}
156+
157+
for (const ref of scanNamedValueReferences(namedValueJson)) {
158+
if (addToFilter(expanded, ref)) {
159+
changed = true;
160+
}
161+
}
162+
}
145163
}
146164

147165
if (iterations > 1) {
@@ -195,33 +213,107 @@ function addToFilter(
195213
*/
196214
export function findTransitiveDependencies(
197215
policies: Map<string, string>,
198-
apis: Map<string, Record<string, unknown>>
216+
apis: Map<string, Record<string, unknown>>,
217+
namedValues: Map<string, Record<string, unknown>> = new Map()
199218
): ResourceDescriptor[] {
200219
const dependencies: ResourceDescriptor[] = [];
201220
const seen = new Set<string>();
221+
const namedValueKeyToName = new Map<string, string>();
222+
223+
const addDependency = (dep: TransitiveDependency): boolean => {
224+
const key = `${dep.type}:${dep.name.toLowerCase()}`;
225+
if (seen.has(key)) {
226+
return false;
227+
}
228+
seen.add(key);
229+
dependencies.push({ type: dep.type, nameParts: [dep.name] });
230+
return true;
231+
};
232+
233+
for (const [name] of namedValues) {
234+
namedValueKeyToName.set(name.toLowerCase(), name);
235+
}
236+
237+
const namedValueQueue: string[] = [];
202238

203239
// Scan all policies
204240
for (const [, policyXml] of policies) {
205241
for (const dep of scanPolicyReferences(policyXml)) {
206-
const key = `${dep.type}:${dep.name.toLowerCase()}`;
207-
if (!seen.has(key)) {
208-
seen.add(key);
209-
dependencies.push({ type: dep.type, nameParts: [dep.name] });
242+
if (addDependency(dep) && dep.type === ResourceType.NamedValue) {
243+
namedValueQueue.push(dep.name);
210244
}
211245
}
212246
}
213247

214248
// Scan API version set references
215249
for (const [, apiJson] of apis) {
216250
const dep = scanApiVersionSetReference(apiJson);
217-
if (dep) {
218-
const key = `${dep.type}:${dep.name.toLowerCase()}`;
219-
if (!seen.has(key)) {
220-
seen.add(key);
221-
dependencies.push({ type: dep.type, nameParts: [dep.name] });
251+
if (dep && addDependency(dep) && dep.type === ResourceType.NamedValue) {
252+
namedValueQueue.push(dep.name);
253+
}
254+
}
255+
256+
// Expand named value chains (e.g. A references B, B references C).
257+
while (namedValueQueue.length > 0) {
258+
const currentName = namedValueQueue.shift();
259+
if (!currentName) {
260+
continue;
261+
}
262+
263+
const canonicalName = namedValueKeyToName.get(currentName.toLowerCase());
264+
if (!canonicalName) {
265+
continue;
266+
}
267+
268+
const namedValueJson = namedValues.get(canonicalName);
269+
if (!namedValueJson) {
270+
continue;
271+
}
272+
273+
for (const dep of scanNamedValueReferences(namedValueJson)) {
274+
if (addDependency(dep) && dep.type === ResourceType.NamedValue) {
275+
namedValueQueue.push(dep.name);
222276
}
223277
}
224278
}
225279

226280
return dependencies;
227281
}
282+
283+
/**
284+
* Scan named value JSON payload for references to other named values.
285+
*/
286+
function scanNamedValueReferences(namedValueJson: Record<string, unknown>): TransitiveDependency[] {
287+
const refs: TransitiveDependency[] = [];
288+
const stack: unknown[] = [namedValueJson];
289+
290+
while (stack.length > 0) {
291+
const value = stack.pop();
292+
if (typeof value === 'string') {
293+
for (const match of value.matchAll(NAMED_VALUE_PATTERN)) {
294+
if (match[1]) {
295+
refs.push({
296+
type: ResourceType.NamedValue,
297+
name: match[1].trim(),
298+
});
299+
}
300+
}
301+
continue;
302+
}
303+
304+
if (Array.isArray(value)) {
305+
for (const item of value) {
306+
stack.push(item);
307+
}
308+
continue;
309+
}
310+
311+
if (value && typeof value === 'object') {
312+
for (const child of Object.values(value as Record<string, unknown>)) {
313+
stack.push(child);
314+
}
315+
}
316+
}
317+
318+
return refs;
319+
}

tests/unit/services/resource-publisher.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,48 @@ describe('resource-publisher', () => {
295295
expect(query['token'][0]).toBe(`{{${authTokenName}}}`);
296296
});
297297

298+
it('should preserve unknown named value references', async () => {
299+
const client = createMockClient();
300+
const store = createMockStore();
301+
const knownName = 'Known-Token';
302+
303+
store.readResource.mockResolvedValue({
304+
name: 'my-backend',
305+
properties: {
306+
credentials: {
307+
authorization: {
308+
parameter: '{{unknown-token}}',
309+
},
310+
query: {
311+
token: ['{{known-token}}'],
312+
},
313+
},
314+
},
315+
});
316+
317+
store.listResources.mockResolvedValue([
318+
{ type: ResourceType.Backend, nameParts: ['my-backend'] },
319+
{ type: ResourceType.NamedValue, nameParts: [knownName] },
320+
]);
321+
322+
const descriptor: ResourceDescriptor = {
323+
type: ResourceType.Backend,
324+
nameParts: ['my-backend'],
325+
};
326+
327+
await publishResource(client, store, testContext, descriptor, testConfig);
328+
329+
const putCall = client.putResource.mock.calls[0];
330+
const putJson = putCall[2] as Record<string, unknown>;
331+
const props = putJson.properties as Record<string, unknown>;
332+
const credentials = props.credentials as Record<string, unknown>;
333+
const authorization = credentials.authorization as Record<string, unknown>;
334+
const query = credentials.query as Record<string, string[]>;
335+
336+
expect(authorization.parameter).toBe('{{unknown-token}}');
337+
expect(query['token'][0]).toBe(`{{${knownName}}}`);
338+
});
339+
298340
it('should preserve opaque JSON properties', async () => {
299341
const client = createMockClient();
300342
const store = createMockStore();

0 commit comments

Comments
 (0)