Skip to content

Commit 438bd0b

Browse files
authored
feat(core): integrate construct annotations into validation report (#37712)
### Reason for this change Part of the [Validations RFC](aws/aws-cdk-rfcs#899). This PR furthers the RFC by integrating construct annotations (warnings and errors added via `Annotations.of()` or `Validations.of()`) into the existing policy validation report. Currently, annotations are only surfaced through the CLI's standard metadata display and are not part of the validation report, meaning users who rely on the report for compliance visibility don't see annotation-based issues alongside plugin violations. ### Description of changes Integrate construct annotations into the existing validation report pipeline by collecting annotation metadata post-synthesis and converting it into a `NamedValidationPluginReport` with source `"Construct Annotations"`. This is **not** the final unified report format discussed in the RFC — it is an integration of annotations as a "plugin" into the existing report framework and structure. **`core/lib/private/synthesis.ts`:** - `collectAnnotationReport()` — walks the full construct tree (including across Stage boundaries via `iterateDfsPreorder`) to collect `aws:cdk:warning` and `aws:cdk:error` metadata entries, converting them to `PolicyViolation` format. Violations are grouped by rule name + severity + description so that multiple constructs triggering the same rule appear as one violation with multiple resources. - `extractRuleName()` — extracts the rule ID from `[ack: <id>]` tags when available; falls back to generic `aws-cdk:warning` / `aws-cdk:error` for untagged annotations (these cannot be acknowledged, so uniqueness is not required). Includes a coupling note documenting the dependency on the `ackTag()` format in `annotations.ts`. - `findNearestResource()` — maps a construct to its nearest `CfnResource` for logical ID and template path resolution - `invokeValidationPlugins()` — calls `collectAnnotationReport()` after plugin execution and merges the result into the `reports[]` array **`core/lib/validation/private/report.ts`:** - `formatJson` filter changed from `!rep.success` to `!rep.success || rep.violations.length > 0` so that warning-only reports (which are `success: true`) still render their violations. This is intentional — violations should always be visible regardless of the overall success status. This broadens behavior for all validation sources: a plugin returning `success: true` with violations would now appear in the report when it previously didn't. - Report headers renamed from "Plugin Report" / "Plugin:" to "Validation Report" / "Source:" to accommodate non-plugin validation sources **⚠️ User-visible output changes:** The report header and summary table labels have changed (`Plugin Report` → `Validation Report`, `Plugin:` → `Source:`, `Plugin` column → `Source`). CI scripts or tools that parse the text report output may need updating. **`core/test/validation/validation.test.ts`:** - 10 new tests covering: annotation warnings in report, annotation errors causing failure, acknowledged warnings excluded, partial acknowledgment via `Validations.of().acknowledge()`, annotations alongside plugins, annotations without plugins, orphan constructs (verifying construct path fallback), `Validations.of().addWarning`, `Validations.of().addError`, and `extractRuleName` regex coupling with `addWarningV2` format - Updated test helpers to match renamed report headers #### Sample output Below is the validation report output showing a plugin (CfnGuardValidator) and construct annotations side-by-side: ``` Performing Policy Validations Validation Report ----------------- ╔═══════════════════════════════╗ ║ Validation Report ║ ║ Source: CfnGuardValidator ║ ║ Version: N/A ║ ║ Status: failure ║ ╚═══════════════════════════════╝ (Violations) S3_BUCKET_VERSIONING_ENABLED (1 occurrences) Severity: high Occurrences: - Construct Path: DemoStack/MyBucket/Resource - Template Path: <outdir>/DemoStack.template.json - Creation Stack: └── DemoStack (DemoStack) │ Construct: constructs.Construct └── MyBucket (DemoStack/MyBucket) └── Resource (DemoStack/MyBucket/Resource) - Resource ID: MyBucketF68F3FF0 - Template Locations: > Properties/VersioningConfiguration Description: S3 Bucket should have versioning enabled How to fix: Set the "VersioningConfiguration.Status" property to "Enabled" ╔═══════════════════════════════════╗ ║ Validation Report ║ ║ Source: Construct Annotations ║ ║ Version: N/A ║ ║ Status: failure ║ ╚═══════════════════════════════════╝ (Violations) @aws-cdk/aws-s3:bucketNotEncrypted (1 occurrences) Severity: warning Occurrences: - Construct Path: DemoStack/MyBucket/Resource - Template Path: <outdir>/DemoStack.template.json - Creation Stack: └── DemoStack (DemoStack) └── MyBucket (DemoStack/MyBucket) └── Resource (DemoStack/MyBucket/Resource) - Resource ID: MyBucketF68F3FF0 - Template Locations: Description: This bucket does not have default encryption enabled [ack: @aws-cdk/aws-s3:bucketNotEncrypted] aws-cdk:error (1 occurrences) Severity: error Occurrences: - Construct Path: DemoStack/MyQueue/Resource - Template Path: <outdir>/DemoStack.template.json - Creation Stack: └── DemoStack (DemoStack) └── MyQueue (DemoStack/MyQueue) └── Resource (DemoStack/MyQueue/Resource) - Resource ID: MyQueueE6CA6235 - Template Locations: Description: Queue does not have a dead letter queue configured annotation::TopicNoEncryption (1 occurrences) Severity: warning Occurrences: - Construct Path: DemoStack/MyTopic/Resource - Template Path: <outdir>/DemoStack.template.json - Creation Stack: └── DemoStack (DemoStack) └── MyTopic (DemoStack/MyTopic) └── Resource (DemoStack/MyTopic/Resource) - Resource ID: MyTopic86869434 - Template Locations: Description: SNS Topic is not encrypted at rest [ack: annotation::TopicNoEncryption] Policy Validation Report Summary ╔═══════════════════════╤═════════╗ ║ Source │ Status ║ ╟───────────────────────┼─────────╢ ║ CfnGuardValidator │ failure ║ ╟───────────────────────┼─────────╢ ║ Construct Annotations │ failure ║ ╚═══════════════════════╧═════════╝ Validation failed. See the validation report above for details ``` ### Describe any new or updated permissions being added N/A ### Description of how you validated changes - `tsc --noEmit` passes cleanly - All 36 validation tests pass (26 existing + 10 new) - Visual report output verified with a demo app exercising all three annotation paths (`Annotations.of().addWarningV2`, `Annotations.of().addError`, `Validations.of().addWarning`) ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent be61e9c commit 438bd0b

12 files changed

Lines changed: 603 additions & 36 deletions

File tree

allowed-breaking-changes.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4167,3 +4167,6 @@ removed-mutability:aws-cdk-lib.aws_s3.BucketBase.grants
41674167

41684168
# Parameter was made optional in override but base class requires it (JSII5008). Method is @deprecated.
41694169
new-argument:aws-cdk-lib.aws_events_targets.LogGroupTargetInput.fromObject
4170+
4171+
# This interface is new; its not depended on yet
4172+
weakened:aws-cdk-lib.PolicyViolatingResource

packages/aws-cdk-lib/core/lib/private/stack-metadata.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ function addValidationPluginInfo(scope: IConstruct, allConstructInfos: Construct
149149
done = true;
150150
}
151151
if (stage) {
152-
allConstructInfos.push(...stage.policyValidationBeta1.map(
152+
allConstructInfos.push(...stage._validationPlugins.map(
153153
plugin => {
154154
return {
155155
fqn: pluginFqn(plugin),

packages/aws-cdk-lib/core/lib/private/synthesis.ts

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,30 @@ import * as fs from 'fs';
22
import * as path from 'path';
33
import * as private_cxapi from '@aws-cdk/cloud-assembly-api';
44
import type { IConstruct } from 'constructs';
5+
import { iterateDfsPreorder } from './construct-iteration';
6+
import { generateFeatureFlagReport } from './feature-flag-report';
7+
import { lit } from './literal-string';
58
import { MetadataResource } from './metadata-resource';
69
import { prepareApp } from './prepare-app';
710
import { TreeMetadata } from './tree-metadata';
11+
import * as cxschema from '../../../cloud-assembly-schema';
12+
import * as cxapi from '../../../cx-api';
813
import { _convertCloudAssemblyBuilder } from '../../../cx-api/lib/legacy-moved';
914
import { Annotations } from '../annotations';
1015
import { App } from '../app';
1116
import { _aspectTreeRevisionReader, AspectApplication, AspectPriority, Aspects } from '../aspect';
1217
import { AssumptionError, UnscopedValidationError } from '../errors';
18+
import { FeatureFlags } from '../feature-flags';
1319
import { FileSystem } from '../fs';
1420
import { Stack } from '../stack';
1521
import type { ISynthesisSession } from '../stack-synthesizers/types';
1622
import type { StageSynthesisOptions } from '../stage';
1723
import { Stage } from '../stage';
1824
import type { IPolicyValidationPlugin } from '../validation';
19-
import { generateFeatureFlagReport } from './feature-flag-report';
20-
import { lit } from './literal-string';
2125
import { ConstructTree } from '../validation/private/construct-tree';
2226
import type { NamedValidationPluginReport } from '../validation/private/report';
2327
import { PolicyValidationReportFormatter } from '../validation/private/report';
28+
import type { PolicyViolation, PolicyViolatingResource } from '../validation/report';
2429

2530
const POLICY_VALIDATION_FILE_PATH = 'policy-validation-report.json';
2631
const VALIDATION_REPORT_PRETTY_CONTEXT = '@aws-cdk/core:validationReportPrettyPrint';
@@ -97,6 +102,108 @@ function getAssemblies(root: App, rootAssembly: private_cxapi.CloudAssembly): Ma
97102
return assemblies;
98103
}
99104

105+
/**
106+
* The plugin name used for annotation-based violations in the validation report.
107+
*/
108+
const ANNOTATION_PLUGIN_NAME = 'Construct Annotations';
109+
110+
/**
111+
* Collect annotation metadata (warnings and errors) from the construct tree
112+
* and convert them into a NamedValidationPluginReport that can be merged
113+
* into the same report pipeline as plugin violations.
114+
*
115+
* Unlike `visit()`, this walks the entire construct tree including across
116+
* Stage boundaries. This is intentional: the validation report is a single
117+
* global output (not per-stage), and a plugin registered at the App level
118+
* sees templates from all stages. Annotations should have the same visibility.
119+
*/
120+
function collectAnnotationReport(root: IConstruct, outdir: string): NamedValidationPluginReport | undefined {
121+
// Group violations by rule so that multiple constructs triggering the same
122+
// rule appear as one violation with multiple violatingResources, matching
123+
// how plugins report violations. The key includes description so that
124+
// generic aws-cdk:error/aws-cdk:warning annotations with different messages
125+
// are kept separate.
126+
const violationMap = new Map<string, PolicyViolation & { violatingResources: PolicyViolatingResource[] }>();
127+
128+
for (const construct of iterateDfsPreorder(root)) {
129+
for (const entry of construct.node.metadata) {
130+
if (entry.type !== cxschema.ArtifactMetadataEntryType.WARN && entry.type !== cxschema.ArtifactMetadataEntryType.ERROR) {
131+
continue;
132+
}
133+
134+
const message = entry.data as string;
135+
const severity = entry.type === cxschema.ArtifactMetadataEntryType.ERROR ? 'error' : 'warning';
136+
const ruleName = extractRuleName(message, severity);
137+
138+
// Resolve template path if the construct is inside a Stack
139+
let templatePath: string | undefined;
140+
try {
141+
templatePath = path.join(outdir, Stack.of(construct).templateFile);
142+
} catch {
143+
// Construct is not inside a Stack (e.g. attached to App or Stage)
144+
}
145+
146+
const violatingResource: PolicyViolatingResource = {
147+
constructPath: construct.node.path,
148+
templatePath,
149+
locations: [],
150+
};
151+
152+
const key = `${ruleName}|${severity}|${message}`;
153+
const existing = violationMap.get(key);
154+
if (existing) {
155+
existing.violatingResources.push(violatingResource);
156+
} else {
157+
violationMap.set(key, {
158+
ruleName,
159+
description: message,
160+
severity,
161+
violatingResources: [violatingResource],
162+
});
163+
}
164+
}
165+
}
166+
167+
const violations = Array.from(violationMap.values());
168+
if (violations.length === 0) {
169+
return undefined;
170+
}
171+
172+
const hasErrors = violations.some(v => v.severity === 'error');
173+
return {
174+
pluginName: ANNOTATION_PLUGIN_NAME,
175+
success: !hasErrors,
176+
violations,
177+
};
178+
}
179+
180+
/**
181+
* Extract a rule name from an annotation message.
182+
*
183+
* Annotations added via `addWarningV2` or `addInfoV2` include an `[ack: <id>]`
184+
* tag in the message. When present, the id is used as the rule name — this is
185+
* the deterministic, preferred path.
186+
*
187+
* Annotations added via the older `addWarning`, `addError`, or `addInfo` APIs
188+
* do not include an ack tag. In that case, a generic identifier based on the
189+
* severity is used (e.g. `aws-cdk:warning`, `aws-cdk:error`). These annotations
190+
* cannot be acknowledged, so uniqueness of the rule name is not required. The
191+
* full message is available in the violation's `description` field.
192+
*
193+
* COUPLING NOTE: The `[ack: <id>]` format is produced by the `ackTag()` helper
194+
* in `annotations.ts`. There is no structured metadata field for the ack id —
195+
* it is embedded in the message string. If the tag format changes, this regex
196+
* must be updated to match. See the test 'extractRuleName regex matches
197+
* addWarningV2 ack tag format' which verifies this coupling.
198+
*/
199+
function extractRuleName(message: string, severity: string): string {
200+
const ackMatch = message.match(/\[ack: ([^\]]+)\]/);
201+
if (ackMatch) {
202+
return ackMatch[1];
203+
}
204+
return `aws-cdk:${severity}`;
205+
}
206+
100207
/**
101208
* Invoke validation plugins for all stages in an App.
102209
*/
@@ -107,7 +214,7 @@ function invokeValidationPlugins(root: IConstruct, outdir: string, assembly: pri
107214
const templatePathsByPlugin: Map<IPolicyValidationPlugin, string[]> = new Map();
108215
visitAssemblies(root, 'post', construct => {
109216
if (Stage.isStage(construct)) {
110-
for (const plugin of construct.policyValidationBeta1) {
217+
for (const plugin of construct._validationPlugins) {
111218
if (!templatePathsByPlugin.has(plugin)) {
112219
templatePathsByPlugin.set(plugin, []);
113220
}
@@ -148,6 +255,16 @@ function invokeValidationPlugins(root: IConstruct, outdir: string, assembly: pri
148255
}
149256
}
150257

258+
// Collect annotation-based violations and merge into the report pipeline
259+
// when the feature flag is enabled. When disabled, annotations are only
260+
// displayed through the CLI's standard metadata output.
261+
if (FeatureFlags.of(root).isEnabled(cxapi.ANNOTATIONS_IN_VALIDATION_REPORT)) {
262+
const annotationReport = collectAnnotationReport(root, assembly.directory);
263+
if (annotationReport) {
264+
reports.push(annotationReport);
265+
}
266+
}
267+
151268
if (reports.length > 0) {
152269
const tree = new ConstructTree(root);
153270
const formatter = new PolicyValidationReportFormatter(tree);

packages/aws-cdk-lib/core/lib/stage.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { PermissionsBoundary } from './permissions-boundary';
77
import { synthesize } from './private/synthesis';
88
import { type IPropertyInjector, PropertyInjectors } from './prop-injectors';
99
import type { IPolicyValidationPlugin, IPolicyValidationPluginBeta1 } from './validation';
10+
import { _toBeta1Plugin } from './validation';
1011
import * as public_cxapi from '../../cx-api';
1112
import { _convertCloudAssembly, _convertCloudAssemblyBuilder } from '../../cx-api';
1213
import { lit } from './private/literal-string';
@@ -185,7 +186,7 @@ export class Stage extends Construct {
185186
* @default - no validation plugins are used
186187
*/
187188
public get policyValidationBeta1(): IPolicyValidationPluginBeta1[] {
188-
return [...this._policyValidation];
189+
return this._policyValidation.map(_toBeta1Plugin);
189190
}
190191

191192
private readonly _policyValidation: IPolicyValidationPlugin[] = [];
@@ -229,6 +230,15 @@ export class Stage extends Construct {
229230
this._policyValidation.push(...plugins);
230231
}
231232

233+
/**
234+
* Returns the raw validation plugins without Beta1 wrapping.
235+
*
236+
* @internal
237+
*/
238+
public get _validationPlugins(): IPolicyValidationPlugin[] {
239+
return [...this._policyValidation];
240+
}
241+
232242
/**
233243
* The cloud assembly output directory.
234244
*/

packages/aws-cdk-lib/core/lib/validation/private/report.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,11 @@ export class PolicyValidationReportFormatter {
120120
json.pluginReports.forEach(plugin => {
121121
output.push('');
122122
output.push(table([
123-
[`Plugin: ${plugin.summary.pluginName}`],
123+
[`Source: ${plugin.summary.pluginName}`],
124124
[`Version: ${plugin.version ?? 'N/A'}`],
125125
[`Status: ${plugin.summary.status}`],
126126
], {
127-
header: { content: 'Plugin Report' },
127+
header: { content: 'Validation Report' },
128128
singleLine: true,
129129
columns: [{
130130
paddingLeft: 3,
@@ -155,9 +155,9 @@ export class PolicyValidationReportFormatter {
155155
for (const construct of constructs) {
156156
output.push('');
157157
output.push(` - Construct Path: ${construct.constructPath ?? 'N/A'}`);
158-
output.push(` - Template Path: ${construct.templatePath}`);
158+
output.push(` - Template Path: ${construct.templatePath ?? 'N/A'}`);
159159
output.push(` - Creation Stack:\n\t${this.reportTrace.formatPrettyPrinted(construct.constructPath)}`);
160-
output.push(` - Resource ID: ${construct.resourceLogicalId}`);
160+
output.push(` - Resource ID: ${construct.resourceLogicalId ?? 'N/A'}`);
161161
if (construct.locations) {
162162
output.push(' - Template Locations:');
163163
for (const location of construct.locations) {
@@ -180,7 +180,7 @@ export class PolicyValidationReportFormatter {
180180
output.push('Policy Validation Report Summary');
181181
output.push('');
182182
output.push(table([
183-
['Plugin', 'Status'],
183+
['Source', 'Status'],
184184
...reps.map(rep => [rep.pluginName, rep.success ? 'success' : 'failure']),
185185
], { }));
186186

@@ -191,7 +191,12 @@ export class PolicyValidationReportFormatter {
191191
return {
192192
title: 'Validation Report',
193193
pluginReports: reps
194-
.filter(rep => !rep.success)
194+
// Include reports that failed OR have violations to render. This is
195+
// broader than the original `!rep.success` filter: a source that
196+
// returns success=true with violations (e.g. annotation warnings)
197+
// will now appear in the report. This is intentional — violations
198+
// should always be visible regardless of the overall success status.
199+
.filter(rep => !rep.success || rep.violations.length > 0)
195200
.map(rep => ({
196201
version: rep.pluginVersion,
197202
summary: {
@@ -207,16 +212,22 @@ export class PolicyValidationReportFormatter {
207212
severity: violation.severity,
208213
violatingResources: violation.violatingResources,
209214
violatingConstructs: violation.violatingResources.map(resource => {
210-
const constructPath = this.tree.getConstructByLogicalId(
211-
path.basename(resource.templatePath),
212-
resource.resourceLogicalId,
213-
)?.node.path;
215+
// Use constructPath from the input if provided (e.g. annotations),
216+
// otherwise derive it from the logical ID via the construct tree.
217+
const constructPath = resource.constructPath ?? (
218+
resource.templatePath && resource.resourceLogicalId
219+
? this.tree.getConstructByLogicalId(
220+
path.basename(resource.templatePath),
221+
resource.resourceLogicalId,
222+
)?.node.path
223+
: undefined
224+
);
214225
return {
215226
constructStack: constructPath ? this.reportTrace.formatJson(constructPath) : undefined,
216227
constructPath: constructPath,
217228
locations: resource.locations,
218-
resourceLogicalId: resource.resourceLogicalId,
219-
templatePath: resource.templatePath,
229+
resourceLogicalId: resource.resourceLogicalId ?? 'N/A',
230+
templatePath: resource.templatePath ?? 'N/A',
220231
};
221232
}),
222233
})),

packages/aws-cdk-lib/core/lib/validation/report.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,26 @@ export interface PolicyViolation {
4949
export interface PolicyViolatingResource {
5050
/**
5151
* The logical ID of the resource in the CloudFormation template.
52+
*
53+
* Required for plugin-sourced violations that operate on CloudFormation
54+
* templates. Mutually exclusive with `constructPath`.
55+
*
56+
* @default - no resource logical ID
5257
*/
53-
readonly resourceLogicalId: string;
58+
readonly resourceLogicalId?: string;
59+
60+
/**
61+
* The construct path of the violating construct.
62+
*
63+
* Use this for violations that originate from constructs rather than
64+
* CloudFormation resources (e.g. annotations added via `Annotations.of()`
65+
* or `Validations.of()`). When provided, the report will use this path
66+
* directly instead of deriving it from the resource logical ID.
67+
* Mutually exclusive with `resourceLogicalId`.
68+
*
69+
* @default - construct path is derived from the resource logical ID
70+
*/
71+
readonly constructPath?: string;
5472

5573
/**
5674
* The locations in the CloudFormation template that pose the violations.
@@ -59,8 +77,10 @@ export interface PolicyViolatingResource {
5977

6078
/**
6179
* The path to the CloudFormation template that contains this resource
80+
*
81+
* @default - no template path
6282
*/
63-
readonly templatePath: string;
83+
readonly templatePath?: string;
6484
}
6585

6686
/**

packages/aws-cdk-lib/core/lib/validation/validation.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { PolicyValidationPluginReport, PolicyValidationPluginReportBeta1 } from './report';
1+
import type { PolicyValidationPluginReport, PolicyValidationPluginReportBeta1, PolicyViolatingResourceBeta1, PolicyViolationBeta1 } from './report';
22

33
/**
44
* Represents a validation plugin that will be executed during synthesis
@@ -121,3 +121,42 @@ export interface IPolicyValidationContextBeta1 {
121121
*/
122122
readonly templatePaths: string[];
123123
}
124+
125+
/**
126+
* Convert an `IPolicyValidationPlugin` to an `IPolicyValidationPluginBeta1`.
127+
*
128+
* The stable `PolicyViolatingResource` interface has optional `resourceLogicalId`
129+
* and `templatePath` fields to support annotation-sourced violations. The Beta1
130+
* interface keeps those fields required. This adapter bridges the gap by
131+
* providing fallback values for the optional fields.
132+
*
133+
* @internal
134+
*/
135+
export function _toBeta1Plugin(plugin: IPolicyValidationPlugin): IPolicyValidationPluginBeta1 {
136+
return {
137+
name: plugin.name,
138+
version: plugin.version,
139+
ruleIds: plugin.ruleIds,
140+
validate(context: IPolicyValidationContextBeta1): PolicyValidationPluginReportBeta1 {
141+
const report = plugin.validate(context);
142+
return {
143+
success: report.success,
144+
pluginVersion: report.pluginVersion,
145+
metadata: report.metadata,
146+
violations: report.violations.map((v): PolicyViolationBeta1 => ({
147+
ruleName: v.ruleName,
148+
description: v.description,
149+
fix: v.fix,
150+
severity: v.severity,
151+
ruleMetadata: v.ruleMetadata,
152+
violatingResources: v.violatingResources.map((r): PolicyViolatingResourceBeta1 => ({
153+
resourceLogicalId: r.resourceLogicalId ?? '',
154+
templatePath: r.templatePath ?? '',
155+
locations: r.locations,
156+
})),
157+
})),
158+
};
159+
},
160+
};
161+
}
162+

0 commit comments

Comments
 (0)