TenantAtlas/app/Services/Intune/PolicySnapshotService.php
ahmido 817ad208da feat/027-enrollment-config-subtypes (#31)
expose enrollment config subtypes as their own policy types (limit/platform restrictions/notifications) with preview-only restore risk and proper Graph contracts
classify enrollment configs by their @odata.type + deviceEnrollmentConfigurationType so sync only keeps ESP in windowsEnrollmentStatusPage and the rest stay in their own types, including new restore-normalizer UI blocks + warnings
hydrate enrollment notifications: snapshot fetch now downloads each notification template + localized messages, normalized view surfaces template names/subjects/messages, and restore previews keep preview-only behavior
tenant UI tweaks: Tenant list and detail actions moved into an action group; “Open in Entra” re-added in index, and detail now has “Deactivate” + tests covering the new menu layout and actions
tests added/updated for sync, snapshots, restores, normalized settings, tenant UI, plus Pint/test suite run

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #31
2026-01-04 13:25:15 +00:00

843 lines
29 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphErrorMapper;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use Illuminate\Support\Arr;
use Throwable;
class PolicySnapshotService
{
public function __construct(
private readonly GraphClientInterface $graphClient,
private readonly GraphLogger $graphLogger,
private readonly GraphContractRegistry $contracts,
private readonly SnapshotValidator $snapshotValidator,
private readonly SettingsCatalogDefinitionResolver $definitionResolver,
) {}
/**
* Fetch a policy snapshot from Graph (with optional hydration) for backup/version capture.
*
* @return array{payload:array,metadata:array,warnings:array}|array{failure:array}
*/
public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null): array
{
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$context = [
'tenant' => $tenantIdentifier,
'policy_type' => $policy->policy_type,
'policy_id' => $policy->external_id,
];
$this->graphLogger->logRequest('get_policy', $context);
try {
$options = [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
'platform' => $policy->platform,
];
if ($this->isMetadataOnlyPolicyType($policy->policy_type)) {
$select = $this->metadataOnlySelect($policy->policy_type);
if ($select !== []) {
$options['select'] = $select;
}
}
if ($policy->policy_type === 'deviceCompliancePolicy') {
$options['expand'] = 'scheduledActionsForRule($expand=scheduledActionConfigurations)';
}
$response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, $options);
} catch (Throwable $throwable) {
$mapped = GraphErrorMapper::fromThrowable($throwable, $context);
// For certain policy types experiencing upstream Graph issues, fall back to metadata-only
if ($this->shouldFallbackToMetadata($policy->policy_type, $mapped->status)) {
return $this->createMetadataOnlySnapshot($policy, $mapped->getMessage(), $mapped->status);
}
return [
'failure' => [
'policy_id' => $policy->id,
'reason' => $mapped->getMessage(),
'status' => $mapped->status,
],
];
}
$this->graphLogger->logResponse('get_policy', $response, $context);
$payload = $response->data['payload'] ?? $response->data;
$metadata = Arr::except($response->data, ['payload']);
$metadataWarnings = $metadata['warnings'] ?? [];
if ($policy->policy_type === 'windowsUpdateRing') {
[$payload, $metadata] = $this->hydrateWindowsUpdateRing(
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
policyId: $policy->external_id,
payload: is_array($payload) ? $payload : [],
metadata: $metadata,
);
}
if (in_array($policy->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) {
[$payload, $metadata] = $this->hydrateConfigurationPolicySettings(
policyType: $policy->policy_type,
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
policyId: $policy->external_id,
payload: is_array($payload) ? $payload : [],
metadata: $metadata
);
}
if ($policy->policy_type === 'groupPolicyConfiguration') {
[$payload, $metadata] = $this->hydrateGroupPolicyConfiguration(
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
policyId: $policy->external_id,
payload: is_array($payload) ? $payload : [],
metadata: $metadata
);
}
if ($policy->policy_type === 'deviceCompliancePolicy') {
[$payload, $metadata] = $this->hydrateComplianceActions(
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
policyId: $policy->external_id,
payload: is_array($payload) ? $payload : [],
metadata: $metadata
);
}
if ($policy->policy_type === 'deviceEnrollmentNotificationConfiguration') {
[$payload, $metadata] = $this->hydrateEnrollmentNotificationTemplates(
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
payload: is_array($payload) ? $payload : [],
metadata: $metadata
);
}
if ($response->failed()) {
$reason = $this->formatGraphFailureReason($response);
if ($this->shouldFallbackToMetadata($policy->policy_type, $response->status)) {
return $this->createMetadataOnlySnapshot($policy, $reason, $response->status);
}
$failure = [
'policy_id' => $policy->id,
'reason' => $reason,
'status' => $response->status,
];
if (! config('graph.stub_on_failure')) {
return ['failure' => $failure];
}
$payload = [
'id' => $policy->external_id,
'type' => $policy->policy_type,
'source' => 'stub',
'warning' => $reason,
];
$metadataWarnings = $response->warnings ?? [$reason];
}
if (! $response->failed() && $this->isMetadataOnlyPolicyType($policy->policy_type)) {
$payload = $this->filterMetadataOnlyPayload($policy->policy_type, is_array($payload) ? $payload : []);
}
$validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []);
$metadataWarnings = array_merge($metadataWarnings, $validation['warnings']);
$odataWarning = Policy::odataTypeWarning(is_array($payload) ? $payload : [], $policy->policy_type, $policy->platform);
if ($odataWarning) {
$metadataWarnings[] = $odataWarning;
}
if (! empty($metadataWarnings)) {
$metadata['warnings'] = array_values(array_unique($metadataWarnings));
}
return [
'payload' => is_array($payload) ? $payload : [],
'metadata' => $metadata,
'warnings' => $metadataWarnings,
];
}
private function formatGraphFailureReason(GraphResponse $response): string
{
$code = $response->meta['error_code']
?? ($response->errors[0]['code'] ?? null)
?? ($response->data['error']['code'] ?? null);
$message = $response->meta['error_message']
?? ($response->errors[0]['message'] ?? null)
?? ($response->data['error']['message'] ?? null)
?? ($response->warnings[0] ?? null);
$reason = 'Graph request failed';
if (is_string($message) && $message !== '') {
$reason = $message;
}
if (is_string($code) && $code !== '') {
$reason = sprintf('%s: %s', $code, $reason);
}
$requestId = $response->meta['request_id'] ?? null;
$clientRequestId = $response->meta['client_request_id'] ?? null;
$suffixParts = [];
if (is_string($clientRequestId) && $clientRequestId !== '') {
$suffixParts[] = sprintf('client_request_id=%s', $clientRequestId);
}
if (is_string($requestId) && $requestId !== '') {
$suffixParts[] = sprintf('request_id=%s', $requestId);
}
if ($suffixParts !== []) {
$reason = sprintf('%s (%s)', $reason, implode(', ', $suffixParts));
}
return $reason;
}
/**
* Hydrate Windows Update Ring payload via derived type cast to capture
* windowsUpdateForBusinessConfiguration-specific properties.
*
* @return array{0:array,1:array}
*/
private function hydrateWindowsUpdateRing(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
{
$odataType = $payload['@odata.type'] ?? null;
$castSegment = $this->deriveTypeCastSegment($odataType);
if ($castSegment === null) {
$metadata['properties_hydration'] = 'skipped';
return [$payload, $metadata];
}
$castPath = sprintf('deviceManagement/deviceConfigurations/%s/%s', urlencode($policyId), $castSegment);
$response = $this->graphClient->request('GET', $castPath, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
]);
if ($response->failed() || ! is_array($response->data)) {
$metadata['properties_hydration'] = 'failed';
return [$payload, $metadata];
}
$metadata['properties_hydration'] = 'complete';
return [array_merge($payload, $response->data), $metadata];
}
private function deriveTypeCastSegment(mixed $odataType): ?string
{
if (! is_string($odataType) || $odataType === '') {
return null;
}
if (! str_starts_with($odataType, '#')) {
return null;
}
$segment = ltrim($odataType, '#');
return $segment !== '' ? $segment : null;
}
private function isMetadataOnlyPolicyType(string $policyType): bool
{
foreach (config('tenantpilot.supported_policy_types', []) as $type) {
if (($type['type'] ?? null) === $policyType) {
return ($type['backup'] ?? null) === 'metadata-only';
}
}
return false;
}
/**
* @return array<int, string>
*/
private function metadataOnlySelect(string $policyType): array
{
$contract = $this->contracts->get($policyType);
$allowedSelect = $contract['allowed_select'] ?? [];
if (! is_array($allowedSelect)) {
return [];
}
return array_values(array_filter(
$allowedSelect,
static fn (mixed $key) => is_string($key) && $key !== '@odata.type'
));
}
private function filterMetadataOnlyPayload(string $policyType, array $payload): array
{
$contract = $this->contracts->get($policyType);
$allowedSelect = $contract['allowed_select'] ?? [];
if (! is_array($allowedSelect) || $allowedSelect === []) {
return $payload;
}
$filtered = [];
foreach ($allowedSelect as $key) {
if (is_string($key) && array_key_exists($key, $payload)) {
$filtered[$key] = $payload[$key];
}
}
return $filtered;
}
/**
* Hydrate configurationPolicies settings via settings subresource (Settings Catalog / Endpoint Security / Baselines).
*
* @return array{0:array,1:array}
*/
private function hydrateConfigurationPolicySettings(string $policyType, string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
{
$strategy = $this->contracts->memberHydrationStrategy($policyType);
$settingsPath = $this->contracts->subresourceSettingsPath($policyType, $policyId);
if ($strategy !== 'subresource_settings' || ! $settingsPath) {
return [$payload, $metadata];
}
$settings = [];
$nextPath = $settingsPath;
$hydrationStatus = 'complete';
while ($nextPath) {
$response = $this->graphClient->request('GET', $nextPath, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
]);
if ($response->failed()) {
$hydrationStatus = 'failed';
break;
}
$data = $response->data;
$pageItems = $data['value'] ?? (is_array($data) ? $data : []);
$settings = array_merge($settings, $pageItems);
$nextLink = $data['@odata.nextLink'] ?? null;
if (! $nextLink) {
break;
}
$nextPath = $this->stripGraphBaseUrl((string) $nextLink);
}
if (! empty($settings)) {
$payload['settings'] = $settings;
// Extract definition IDs and warm cache (T008-T010)
$definitionIds = $this->extractDefinitionIds($settings);
$metadata['definition_count'] = count($definitionIds);
// Warm cache for definitions (non-blocking)
$this->definitionResolver->warmCache($definitionIds);
$metadata['definitions_cached'] = true;
}
$metadata['settings_hydration'] = $hydrationStatus;
return [$payload, $metadata];
}
/**
* Hydrate Administrative Templates (Group Policy Configurations) with definitionValues and presentationValues.
*
* @return array{0:array,1:array}
*/
private function hydrateGroupPolicyConfiguration(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
{
$strategy = $this->contracts->memberHydrationStrategy('groupPolicyConfiguration');
$definitionValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'definitionValues', [
'{id}' => $policyId,
]);
if ($strategy !== 'subresource_definition_values' || ! $definitionValuesPath) {
return [$payload, $metadata];
}
$graphBase = rtrim((string) config('graph.base_url', 'https://graph.microsoft.com'), '/')
.'/'.trim((string) config('graph.version', 'beta'), '/');
$definitionValues = [];
$nextPath = $definitionValuesPath;
$hydrationStatus = 'complete';
while ($nextPath) {
$response = $this->graphClient->request('GET', $nextPath, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
]);
if ($response->failed()) {
$hydrationStatus = 'failed';
break;
}
$definitionValues = array_merge($definitionValues, $response->data['value'] ?? []);
$nextLink = $response->data['@odata.nextLink'] ?? null;
if (! $nextLink) {
break;
}
$nextPath = $this->stripGraphBaseUrl((string) $nextLink);
}
if ($hydrationStatus === 'failed') {
$metadata['warnings'] = array_values(array_unique(array_merge(
$metadata['warnings'] ?? [],
['Hydration failed: could not load Administrative Templates definition values.']
)));
return [$payload, $metadata];
}
$settings = [];
foreach ($definitionValues as $definitionValue) {
if (! is_array($definitionValue)) {
continue;
}
$definition = $definitionValue['definition'] ?? null;
$definitionId = is_array($definition) ? ($definition['id'] ?? null) : null;
$definitionValueId = $definitionValue['id'] ?? null;
if (! is_string($definitionValueId) || $definitionValueId === '') {
continue;
}
if (! is_string($definitionId) || $definitionId === '') {
continue;
}
$presentationValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'presentationValues', [
'{id}' => $policyId,
'{definitionValueId}' => $definitionValueId,
]);
$setting = [
'enabled' => (bool) ($definitionValue['enabled'] ?? false),
'definition@odata.bind' => "{$graphBase}/deviceManagement/groupPolicyDefinitions('{$definitionId}')",
'#Definition_Id' => $definitionId,
'#Definition_displayName' => is_array($definition) ? ($definition['displayName'] ?? null) : null,
'#Definition_classType' => is_array($definition) ? ($definition['classType'] ?? null) : null,
'#Definition_categoryPath' => is_array($definition) ? ($definition['categoryPath'] ?? null) : null,
];
$setting = array_filter($setting, static fn ($value) => $value !== null);
if (! $presentationValuesPath) {
$settings[] = $setting;
continue;
}
$presentationValues = [];
$presentationNext = $presentationValuesPath;
while ($presentationNext) {
$pvResponse = $this->graphClient->request('GET', $presentationNext, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
]);
if ($pvResponse->failed()) {
$metadata['warnings'] = array_values(array_unique(array_merge(
$metadata['warnings'] ?? [],
['Hydration warning: could not load some Administrative Templates presentation values.']
)));
break;
}
$presentationValues = array_merge($presentationValues, $pvResponse->data['value'] ?? []);
$presentationNextLink = $pvResponse->data['@odata.nextLink'] ?? null;
if (! $presentationNextLink) {
break;
}
$presentationNext = $this->stripGraphBaseUrl((string) $presentationNextLink);
}
if ($presentationValues !== []) {
$setting['presentationValues'] = [];
foreach ($presentationValues as $presentationValue) {
if (! is_array($presentationValue)) {
continue;
}
$presentation = $presentationValue['presentation'] ?? null;
$presentationId = is_array($presentation) ? ($presentation['id'] ?? null) : null;
if (! is_string($presentationId) || $presentationId === '') {
continue;
}
$cleanPresentationValue = Arr::except($presentationValue, [
'presentation',
'id',
'lastModifiedDateTime',
'createdDateTime',
]);
$cleanPresentationValue['presentation@odata.bind'] = "{$graphBase}/deviceManagement/groupPolicyDefinitions('{$definitionId}')/presentations('{$presentationId}')";
$label = is_array($presentation) ? ($presentation['label'] ?? null) : null;
if (is_string($label) && $label !== '') {
$cleanPresentationValue['#Presentation_Label'] = $label;
}
$cleanPresentationValue['#Presentation_Id'] = $presentationId;
$setting['presentationValues'][] = $cleanPresentationValue;
}
if ($setting['presentationValues'] === []) {
unset($setting['presentationValues']);
}
}
$settings[] = $setting;
}
$payload['definitionValues'] = $settings;
return [$payload, $metadata];
}
/**
* Hydrate compliance policies with scheduled actions (notification templates).
*
* @return array{0:array,1:array}
*/
private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
{
$existingActions = $payload['scheduledActionsForRule'] ?? null;
if (is_array($existingActions) && $existingActions !== []) {
$metadata['compliance_actions_hydration'] = 'embedded';
return [$payload, $metadata];
}
$path = sprintf('deviceManagement/deviceCompliancePolicies/%s/scheduledActionsForRule', urlencode($policyId));
$options = [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
];
$actions = [];
$nextPath = $path;
$hydrationStatus = 'complete';
while ($nextPath) {
$response = $this->graphClient->request('GET', $nextPath, $options);
if ($response->failed()) {
$hydrationStatus = 'failed';
break;
}
$data = $response->data;
$pageItems = $data['value'] ?? (is_array($data) ? $data : []);
foreach ($pageItems as $item) {
if (is_array($item)) {
$actions[] = $item;
}
}
$nextLink = $data['@odata.nextLink'] ?? null;
if (! $nextLink) {
break;
}
$nextPath = $this->stripGraphBaseUrl((string) $nextLink);
}
if (! empty($actions)) {
$payload['scheduledActionsForRule'] = $actions;
}
$metadata['compliance_actions_hydration'] = $hydrationStatus;
return [$payload, $metadata];
}
/**
* Hydrate enrollment notifications with message template details.
*
* @return array{0:array,1:array}
*/
private function hydrateEnrollmentNotificationTemplates(string $tenantIdentifier, Tenant $tenant, array $payload, array $metadata): array
{
$existing = $payload['notificationTemplateSnapshots'] ?? null;
if (is_array($existing) && $existing !== []) {
$metadata['enrollment_notification_templates_hydration'] = 'embedded';
return [$payload, $metadata];
}
$templateRefs = $payload['notificationTemplates'] ?? null;
if (! is_array($templateRefs) || $templateRefs === []) {
$metadata['enrollment_notification_templates_hydration'] = 'none';
return [$payload, $metadata];
}
$options = [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
];
$snapshots = [];
$failures = 0;
foreach ($templateRefs as $templateRef) {
if (! is_string($templateRef) || $templateRef === '') {
continue;
}
[$channel, $templateId] = $this->parseEnrollmentNotificationTemplateRef($templateRef);
if ($templateId === null) {
$failures++;
continue;
}
$templatePath = sprintf('deviceManagement/notificationMessageTemplates/%s', urlencode($templateId));
$templateResponse = $this->graphClient->request('GET', $templatePath, $options);
if ($templateResponse->failed() || ! is_array($templateResponse->data)) {
$failures++;
continue;
}
$template = Arr::except($templateResponse->data, ['@odata.context']);
$messagesPath = sprintf(
'deviceManagement/notificationMessageTemplates/%s/localizedNotificationMessages',
urlencode($templateId)
);
$messagesResponse = $this->graphClient->request('GET', $messagesPath, $options);
$messages = [];
if ($messagesResponse->failed()) {
$failures++;
} else {
$pageItems = $messagesResponse->data['value'] ?? [];
if (is_array($pageItems)) {
foreach ($pageItems as $message) {
if (is_array($message)) {
$messages[] = Arr::except($message, ['@odata.context']);
}
}
}
}
$snapshots[] = [
'channel' => $channel,
'template_id' => $templateId,
'template' => $template,
'localized_notification_messages' => $messages,
];
}
if ($snapshots === []) {
$metadata['enrollment_notification_templates_hydration'] = 'failed';
return [$payload, $metadata];
}
$payload['notificationTemplateSnapshots'] = $snapshots;
$metadata['enrollment_notification_templates_hydration'] = $failures > 0 ? 'partial' : 'complete';
return [$payload, $metadata];
}
/**
* @return array{0:?string,1:?string}
*/
private function parseEnrollmentNotificationTemplateRef(string $templateRef): array
{
if (! str_contains($templateRef, '_')) {
return [null, $templateRef];
}
[$channel, $templateId] = explode('_', $templateRef, 2);
$channel = trim($channel);
$templateId = trim($templateId);
if ($templateId === '') {
return [$channel !== '' ? $channel : null, null];
}
return [$channel !== '' ? $channel : null, $templateId];
}
/**
* Extract all settingDefinitionId from settings array, including nested children.
*/
private function extractDefinitionIds(array $settings): array
{
$definitionIds = [];
foreach ($settings as $setting) {
// Extract definition ID from settingInstance
if (isset($setting['settingInstance']['settingDefinitionId'])) {
$definitionIds[] = $setting['settingInstance']['settingDefinitionId'];
}
// Handle groupSettingCollectionInstance with children
if (isset($setting['settingInstance']['@odata.type']) &&
str_contains($setting['settingInstance']['@odata.type'], 'groupSettingCollectionInstance')) {
if (isset($setting['settingInstance']['groupSettingCollectionValue'])) {
foreach ($setting['settingInstance']['groupSettingCollectionValue'] as $group) {
if (isset($group['children'])) {
$childIds = $this->extractDefinitionIds($group['children']);
$definitionIds = array_merge($definitionIds, $childIds);
}
}
}
}
}
return array_unique($definitionIds);
}
private function stripGraphBaseUrl(string $nextLink): string
{
$base = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/').'/'.trim(config('graph.version', 'beta'), '/');
if (str_starts_with($nextLink, $base)) {
return ltrim(substr($nextLink, strlen($base)), '/');
}
return $nextLink;
}
/**
* Determine if we should fall back to metadata-only for this policy type and error.
*/
private function shouldFallbackToMetadata(string $policyType, ?int $status): bool
{
// Only fallback on 5xx server errors
if ($status === null || $status < 500 || $status >= 600) {
return false;
}
// Enable fallback for policy types experiencing upstream Graph issues
$fallbackTypes = [
'mamAppConfiguration',
'managedDeviceAppConfiguration',
];
return in_array($policyType, $fallbackTypes, true);
}
/**
* Create a metadata-only snapshot from the Policy model when Graph is unavailable.
*
* @return array{payload:array,metadata:array,warnings:array}
*/
private function createMetadataOnlySnapshot(Policy $policy, string $failureReason, ?int $status): array
{
$odataType = match ($policy->policy_type) {
'mamAppConfiguration' => '#microsoft.graph.targetedManagedAppConfiguration',
'managedDeviceAppConfiguration' => '#microsoft.graph.managedDeviceMobileAppConfiguration',
default => '#microsoft.graph.'.$policy->policy_type,
};
$payload = [
'id' => $policy->external_id,
'displayName' => $policy->display_name,
'@odata.type' => $odataType,
'createdDateTime' => $policy->created_at?->toIso8601String(),
'lastModifiedDateTime' => $policy->updated_at?->toIso8601String(),
];
if ($policy->platform) {
$payload['platform'] = $policy->platform;
}
$metadata = [
'source' => 'metadata_only',
'original_failure' => $failureReason,
'original_status' => $status,
'warnings' => [
sprintf(
'Snapshot captured from local metadata only (Graph API returned %s). Restore preview available, full restore not possible.',
$status ?? 'error'
),
],
];
return [
'payload' => $payload,
'metadata' => $metadata,
'warnings' => $metadata['warnings'],
];
}
}