diff --git a/src/services/transitive-resolver.ts b/src/services/transitive-resolver.ts
index 1251e82..5dab42f 100644
--- a/src/services/transitive-resolver.ts
+++ b/src/services/transitive-resolver.ts
@@ -18,6 +18,8 @@ import { getResourceDescriptorKey } from '../lib/resource-path.js';
* Reference detection patterns for policy XML content.
*/
const NAMED_VALUE_PATTERN = /\{\{([^}]+)\}\}/g;
+const POLICY_BOUNDARY_PATTERN = /|$)||$)|<\/set-body\s*>|/g;
+const ATTRIBUTE_PATTERN = /\s+([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
const BACKEND_PATTERN = /([
* Scan policy XML content for references to other resources.
*
* Detects:
- * - Named values: {{namedValueName}} syntax
+ * - Named values: {{namedValueName}} syntax outside Liquid template bodies
* - Backends:
* - Policy fragments:
*/
export function scanPolicyReferences(policyXml: string): TransitiveDependency[] {
const dependencies: TransitiveDependency[] = [];
+ const namedValueParts: string[] = [];
+ let retainedStart = 0;
+ let liquidDepth = 0;
+
+ for (const boundary of policyXml.matchAll(POLICY_BOUNDARY_PATTERN)) {
+ const tag = boundary[0];
+ if (tag.startsWith('')) {
+ continue;
+ }
+ if (tag.startsWith('')) {
+ if (liquidDepth > 0 && --liquidDepth === 0) {
+ retainedStart = boundary.index;
+ }
+ } else if (liquidDepth > 0) {
+ liquidDepth++;
+ } else {
+ for (const attribute of tag.matchAll(ATTRIBUTE_PATTERN)) {
+ if (attribute[1] === 'template' && (attribute[2] ?? attribute[3]) === 'liquid') {
+ const openingEnd = boundary.index + tag.length;
+ namedValueParts.push(policyXml.slice(retainedStart, openingEnd));
+ retainedStart = openingEnd;
+ liquidDepth = 1;
+ break;
+ }
+ }
+ }
+ }
+ namedValueParts.push(policyXml.slice(retainedStart));
+ const namedValueXml = namedValueParts.join('');
// Named value references
- for (const match of policyXml.matchAll(NAMED_VALUE_PATTERN)) {
+ for (const match of namedValueXml.matchAll(NAMED_VALUE_PATTERN)) {
if (match[1]) {
dependencies.push({
type: ResourceType.NamedValue,
diff --git a/tests/unit/services/extract-service.test.ts b/tests/unit/services/extract-service.test.ts
index 090b820..191230c 100644
--- a/tests/unit/services/extract-service.test.ts
+++ b/tests/unit/services/extract-service.test.ts
@@ -744,6 +744,61 @@ describe('extract-service', () => {
expect(result.exitCode).toBe(0);
});
+ it.each([ResourceType.ServicePolicy, ResourceType.PolicyFragment])(
+ 'extracts %s with Liquid without false lookups or policy changes',
+ async (policyType) => {
+ const policy = `
+ {{real-value}}
+ {{context.Request.MatchedParameters["id"]}}
+
+ {{body.envelope.body.Test_Result.test}}
+ `;
+ const policyResource = { name: 'liquid-policy', properties: { value: policy } };
+ const client = createMockClient(
+ policyType === ResourceType.PolicyFragment
+ ? { [ResourceType.PolicyFragment]: [policyResource] }
+ : {}
+ );
+ client.getResource.mockImplementation(async (_context, descriptor: ResourceDescriptor) => {
+ if (policyType === ResourceType.ServicePolicy && descriptor.type === policyType) {
+ return policyResource;
+ }
+ if (descriptor.type === ResourceType.NamedValue && descriptor.nameParts[0] === 'real-value') {
+ return { name: 'real-value', properties: { value: 'configured-value' } };
+ }
+ return undefined;
+ });
+ const store = createMockStore();
+
+ const result = await runExtraction(client, store, {
+ service: testContext,
+ outputDir: '/output',
+ includeTransitive: true,
+ filter: { apis: [], namedValues: [] },
+ logLevel: LogLevel.INFO,
+ });
+
+ expect(result.exitCode).toBe(0);
+ expect(result.totalErrors).toBe(0);
+ const namedValueRequests = client.getResource.mock.calls
+ .map(([, descriptor]) => descriptor as ResourceDescriptor)
+ .filter((descriptor) => descriptor.type === ResourceType.NamedValue);
+ expect(namedValueRequests).toEqual([
+ expect.objectContaining({ nameParts: ['real-value'] }),
+ ]);
+ if (policyType === ResourceType.ServicePolicy) {
+ expect(store.writeContent).toHaveBeenCalledWith(
+ '/output', expect.objectContaining({ type: policyType }), policy, 'policy'
+ );
+ expect(result.collectedPolicies.get('service-policy')).toBe(policy);
+ } else {
+ expect(store.writeResource).toHaveBeenCalledWith(
+ '/output', expect.objectContaining({ type: policyType }), policyResource
+ );
+ }
+ }
+ );
+
it('should extract backend pool members and policy fragment dependencies transitively', async () => {
const backendId =
'/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiManagement/service/a/backends/member';
diff --git a/tests/unit/services/transitive-resolver.test.ts b/tests/unit/services/transitive-resolver.test.ts
index 8d05947..17837e9 100644
--- a/tests/unit/services/transitive-resolver.test.ts
+++ b/tests/unit/services/transitive-resolver.test.ts
@@ -36,6 +36,113 @@ describe('transitive-resolver', () => {
expect(nvRefs[1]?.name).toBe('secret-2');
});
+ it('ignores Liquid output expressions while retaining real policy dependencies', () => {
+ const policy = `
+
+
+ {{my-secret}}
+
+ {{context.Request.MatchedParameters["id"]}}
+
+
+
+
+
+ {"result": "{{body.envelope.body.Test_Result.test}}"}
+
+
+
+
+ {"code": "{{body.envelope.body.fault.faultcode}}",
+ "message": "{{body.envelope.body.fault.faultstring}}"}
+
+
+
+
+ `;
+
+ expect(scanPolicyReferences(policy)).toEqual([
+ { type: ResourceType.NamedValue, name: 'my-secret' },
+ { type: ResourceType.Backend, name: 'soap-backend' },
+ { type: ResourceType.PolicyFragment, name: 'error-handler' },
+ ]);
+ });
+
+ it.each([
+ 'template="liquid"',
+ "template='liquid'",
+ 'parse-date="false"\n template = "liquid" xsi-nil="blank"',
+ ])('ignores Liquid variables and filters with attributes %s', (attributes) => {
+ const policy = `{{real-value}}`;
+
+ expect(scanPolicyReferences(policy)).toEqual([
+ { type: ResourceType.NamedValue, name: 'real-value' },
+ ]);
+ });
+
+ it('retains named values in non-Liquid bodies and Liquid opening attributes', () => {
+ const policy = `{{plain-value}}
+ {{body.value}}
+ @{ return "{{expression-value}}"; }`;
+
+ expect(scanPolicyReferences(policy)).toEqual([
+ { type: ResourceType.NamedValue, name: 'plain-value' },
+ { type: ResourceType.NamedValue, name: 'parse-date' },
+ { type: ResourceType.NamedValue, name: 'expression-value' },
+ ]);
+ });
+
+ it.each([
+ '{{body.value}}]]>',
+ '{{body.value}}',
+ '{{body.first}}{{body.second}}',
+ '{{body.first}}{{body.second}}',
+ ])('ignores the entire Liquid body despite embedded boundaries: %s', (body) => {
+ const policy = `{{before}}
+ ${body}
+ {{after}}`;
+
+ expect(scanPolicyReferences(policy)).toEqual([
+ { type: ResourceType.NamedValue, name: 'before' },
+ { type: ResourceType.NamedValue, name: 'parse-date' },
+ { type: ResourceType.NamedValue, name: 'after' },
+ ]);
+ });
+
+ it.each([
+ '',
+ '{{comment-value}}]]>',
+ ])('does not treat tag-like text as a Liquid element: %s', (body) => {
+ const policy = `${body}{{plain-value}}
+ {{body.value}}`;
+
+ expect(scanPolicyReferences(policy)).toEqual([
+ { type: ResourceType.NamedValue, name: 'comment-value' },
+ { type: ResourceType.NamedValue, name: 'plain-value' },
+ ]);
+ });
+
+ it('tolerates raw C# expressions and empty Liquid elements without changing backend or fragment scanning', () => {
+ const policy = `@{ return 1 < 2 && true ? "{{expression-value}}" : ""; }
+
+ {{after-empty}}
+
+
+
+ {{body.value}}
+ `;
+
+ expect(scanPolicyReferences(policy)).toEqual([
+ { type: ResourceType.NamedValue, name: 'expression-value' },
+ { type: ResourceType.NamedValue, name: 'parse-date' },
+ { type: ResourceType.NamedValue, name: 'after-empty' },
+ { type: ResourceType.Backend, name: 'body-backend' },
+ { type: ResourceType.PolicyFragment, name: 'body-fragment' },
+ ]);
+ });
+
it('should detect backend references', () => {
const policy = '';
const refs = scanPolicyReferences(policy);