Всем привет! Меня зовут Артём Вичужанин. В разработке я больше пяти лет: начинал с десктопных приложений на Delphi и микропрограмм для контроллеров на C++, позже ушел в мобильную разработку. Сейчас в Naumen я отвечаю за разработку мобильных продуктов, и в рамках проектов регулярно сталкиваюсь с вопросами качества кода и автоматизации.

Артём Вичужанин

iOS-разработчик Naumen

Именно в корпоративной разработке особенно остро чувствуется: чем больше проектов и команд, тем сложнее удерживать единый стиль кода.

В этой статье я делюсь опытом настройки SwiftLint сразу для нескольких репозиториев — так, чтобы кодстайл оставался единым и не расползался со временем.

Почему единый кодстайл расползается

Если вы хоть раз настраивали линтер, вы примерно представляете, что происходит дальше. А если вы настраивали SwiftLint в компании с десятками репозиториев, то знаете продолжение истории: со временем правила неизбежно начинают расползаться по проектам и командам.

В одних проектах лежат старые конфиги, в других — локальные исключения, а корпоративные стандарты существуют где‑то отдельно. В итоге:

  • код‑ревью превращаются в холивары про форматирование вместо обсуждения логики;

  • онбординг новых разработчиков растягивается — непонятно, где искать актуальные правила;

  • поддержка конфигов становится ручной и неблагодарной рутиной.

Из личного опыта: я не раз попадал в ситуацию, когда существовало несколько «уровней» правил. Часть проверялась линтером локально, часть была описана в корпоративной документации. Перед ревью приходилось сначала проверять себя линтером, а потом вручную сверяться с корпоративными документами. Двойная работа, а замечания в MR все равно прилетали.

Когда передо мной поставили задачу внедрить линтер сразу в несколько проектов, стало понятно: без централизованного источника правды мы утонем в ручной синхронизации конфигов и бесконечных обсуждениях кодстайла.

Именно тогда я нашел в SwiftLint механизм, который оказался ключом к унификации — возможность загружать конфигурации по ссылке через параметр parent_config.

SwiftLint: как устроены конфиги

SwiftLint — инструмент статического анализа кода для Swift. Он помогает поддерживать единый стиль: проверяет код по набору правил, выявляет отклонения и предлагает исправления.

Чтобы настроить SwiftLint под себя, достаточно добавить в проект конфигурационный файл.swiftlint.yml, где описаны нужные правила и параметры их работы. Там чаще всего встречаются:

  • disabled_rules — какие встроенные проверки отключаем;

  • opt_in_rules — какие проверки включаем дополнительно;

  • excluded — какие файлы и директории не проверяем;

  • custom_rules — свои правила на основе регулярных выражений.

В SwiftLint есть наследование конфигов: child_config и parent_config. Это открывает возможности для масштабирования. Самый простой сценарий — вложенные конфигурации: в корне проекта лежит базовый.swiftlint.yml, а в папке Tests — свой, который переопределяет нужные параметры. SwiftLint объединяет настройки.

Но есть важное ограничение: как только вы запускаете SwiftLint с кастомным путем через параметр ‑config или конфиг лежит не там и называется не так, магия вложенности перестает работать. Можно перечислять несколько файлов вручную, но это быстро становится неудобно.

И вот здесь появляется ключевой параметр — parent_config.

Remote Config: ключевая фича для масштабирования

Параметр parent_config в файле конфигурации позволяет выстраивать цепочку конфигураций независимо от того, где лежат файлы и как вы подключаете линтер. Главное — в parent_config можно указывать не только локальные пути, но и ссылки.

Это означает, что базовый конфиг можно хранить централизованно, а все проекты будут наследовать его и автоматически подтягивать обновления.

Идея выглядит простой, но на практике возникает много вопросов: как работает загрузка, что происходит при ошибках, как ведет себя кеш и можно ли использовать это в продакшене без боли. Чтобы разобраться, пришлось копать глубже.

«ИИ — твой бро?» — не всегда: почему пришлось лезть в исходники

Когда я взялся за задачу, поступил как любой уважающий себя разработчик в 2025 году: пошел за советом к ИИ. 

ИИ‑модели дружно вселяли надежду, показывали примеры конфигураций, писали скрипты и объясняли «как правильно». Но все, что они предложили, не работало. Более того — некоторые параметры и ключи, о которых они рассказывали, вообще не существовали в SwiftLint.

Часть моделей потом «извинялась» и предлагала альтернативу: написать свой скрипт — вручную скачивать конфиг, потом запускать линтер. Красиво, но это не то, чего хотелось: дополнительные ресурсы, поддержка, лишняя сложность в CI.

В итоге я сделал по‑старинке: залез в репозиторий SwiftLint, посмотрел issues, пул‑реквесты и документацию и нашел нужную зацепку в release notes.

Как Remote Config устроен внутри SwiftLint 

Оказалось, что возможность загружать конфигурации по ссылке появилась еще в декабре 2020 года. Документирована она довольно скупо — буквально в пару строк, поэтому дальше пришлось разбираться по исходникам.

По документации логика такая: если в.swiftlint.yml указан parent_config со ссылкой, SwiftLint пытается скачать актуальную версию удаленной конфигурации. Если не удается, например, сервер не отвечает или время ожидания превышено, — SwiftLint использует кешированную копию конфигурации. А если кеша еще нет, то завершает запуск ошибкой.

Поддержка Remote Config в SwiftLint реализована несколькими связанными частями:

  • Configuration.FileGraph.FilePath — определяет, локальный путь это или HTTP/HTTPS;

  • Configuration.FileGraph.Vertex — вершина графа конфигураций;

  • Configuration.FileGraph — строит дерево конфигураций и объединяет их;

  • Configuration+Remote.swift — отвечает за загрузку и кеширование.

init(string: String, rootDirectory: String, isInitialVertex: Bool) {
    originalRootDirectory = rootDirectory
    if string.hasPrefix("http://") || string.hasPrefix("https://") {
        originalRemoteString = string
        filePath = .promised(urlString: string)
    } else {
        originalRemoteString = nil
        filePath = .existing(
            path: string.bridge().absolutePathRepresentation(rootDirectory: rootDirectory)
        )
    }
    self.isInitialVertex = isInitialVertex
}

Именно здесь словарь config‑файла превращается в объекты. SwiftLint просто проверяет. Если путь начинается с «http» — значит конфигурация удаленная, и ее нужно загрузить по сети; если нет — берется локальный файл.

do {
    var fileGraph = FileGraph(
        commandLineChildConfigs: configurationFiles,
        rootDirectory: currentWorkingDirectory,
        ignoreParentAndChildConfigs: ignoreParentAndChildConfigs
    )
}

Дальше SwiftLint создает граф зависимостей и проходит по всем узлам.

Загрузка и кеширование

// Handle wrong url format
guard let url: URL = URL(string: urlString) else {
    throw Issue.genericWarning("Invalid configuration entry: \"\(urlString)\" isn't a valid url.")
}

// Load from url
var taskResult: (Data?, URLResponse?, (any Error)?)?
var taskDone: Bool = false

// `.ephemeral` disables caching (which we don't want to be managed by the system)
let task: URLSessionDataTask = URLSession(configuration: .ephemeral).dataTask(with: url) { data: Data?, response: URLResponse?, error: (any Error)? in
    taskResult = (data, response, error)
    taskDone = true
}
task.resume()
let timeout = cachedFilePath == nil ? remoteConfigTimeout : remoteConfigTimeoutIfCached
let startDate: Date = Date()

// Block main thread until timeout is reached / task is done
while true {
    if taskDone { break }
    if Date().timeIntervalSince(startDate) > timeout {
        task.cancel()
        break
    }
    usleep(50_000) // Sleep for 50 ms
}

Когда линтер встречает ссылку, он запускает загрузку через URLSession. SwiftLint проверяет валидность ссылки, делает запрос, ждет заданный таймаут (по умолчанию 2 секунды без кеша и 1 секунда при его наличии), проверяет ошибки и статус ответа 200. После этого можно декодировать данные в UTF-8.

// Handle wrong data
guard
    taskResult.2 == nil, // No error
    (taskResult.1 as? HTTPURLResponse)?.statusCode == 200,
    let configStr: String = taskResult.0.flatMap { String(data: $0, encoding: .utf8) }
else {
    return try handleWrongData(
        urlString: urlString,
        cachedFilePath: cachedFilePath,
        taskDone: taskDone,
        timeout: timeout
    )
}

// Add comment line at the top of the config string
let formatter: DateFormatter = DateFormatter()
formatter.dateFormat = "dd/MM/yyyy 'at' HH:mm:ss"
let configString =
    "#\n"
    + "# Automatically downloaded from \(urlString) by SwiftLint on \(formatter.string(from: Date())).\n"
    + "#\n"
    + configStr

// Create file
let path = filePath(for: urlString, rootDirectory: rootDirectory)
return FileManager.default.createFile(
    atPath: path,
    contents: Data(configString.utf8),
    attributes: [:]
) ? path : nil

Кеш складывается в .swiftlint/RemoteConfigCache/v1. Рядом автоматически создается.gitignore, чтобы кеш не попадал в репозиторий. Версионирование кеша тоже предусмотрено — при смене формата старые данные не конфликтуют с новыми.

Fallback-логика

private func maintainRemoteConfigCache(rootDirectory: String) throws {
    // Create directory if needed
    let directory = Configuration.FileGraph.FilePath.versionedRemoteCachePath
        .bridge().absolutePathRepresentation(rootDirectory: rootDirectory)
    if !FileManager.default.fileExists(atPath: directory) {
        try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
    }

    // Add gitignore entry if needed
    if !FileManager.default.fileExists(atPath: Configuration.FileGraph.FilePath.gitignorePath) {
        guard FileManager.default.createFile(
            atPath: Configuration.FileGraph.FilePath.gitignorePath,
            contents: Data(),
            attributes: [:]
        ) else {
            throw Issue.genericWarning("Issue maintaining remote config cache.")
        }
    } else {
        var contents: String = try String(
            contentsOfFile: Configuration.FileGraph.FilePath.gitignorePath,
            encoding: .utf8
        )
        if !contents.contains(requiredGitignoreAppendix) {
            contents += "\n\n\(newGitignoreAppendix)"
            try contents.write(
                toFile: Configuration.FileGraph.FilePath.gitignorePath,
                atomically: true,
                encoding: .utf8
            )
        }
    }
}

При обработке ошибок SwiftLint ведет себя довольно умно:

  • Если загрузка не удалась, но кеш есть — использует кеш и просто предупреждает: «Удаленная конфигурация недоступна, используем кеш».

  • Если кеша нет — тогда выбрасывается предупреждение, но линтер не всегда падает: он умеет откатываться на дефолтные настройки.

Благодаря такому поведению SwiftLint устойчив к перебоям сети и позволяет спокойно работать даже без доступа к удаленному хранилищу.

Дальше, после загрузки, начинается обработка содержимого: SwiftLint ищет внутри YAML‑файлов секции child_config и parent_config и строит из них дерево зависимостей. 

Важно, что удаленные конфиги не могут ссылаться на локальные — это защита от рекурсий и неоднозначностей.

private mutating func processPossibleReference(
    ofType type: EdgeType,
    from vertex: Vertex,
    remoteConfigTimeoutOverride: TimeInterval?,
    remoteConfigTimeoutIfCachedOverride: TimeInterval?
) throws {
    // Local vertices are allowed to have local / remote references
    // Remote vertices are only allowed to have remote references
    if vertex.originatesFromRemote, !referencedVertex.originatesFromRemote {
        throw Issue.genericWarning("Remote configs are not allowed to reference local configs.")
    }

    let existingVertex = findPossiblyExistingVertex(sameAs: referencedVertex)
    let existingVertexCopy = existingVertex.map { $0.copy(withNewRootDirectory: rootDirectory) }

    edges.insert(
        type == .childConfig
            ? Edge(parent: vertex, child: existingVertexCopy ?? referencedVertex)
            : Edge(parent: existingVertexCopy ?? referencedVertex, child: vertex)
    )
}

Завершается все валидацией графа: SwiftLint проверяет, нет ли циклов или нескольких родителей у одной конфигурации. Если все ок, строится линейная цепочка наследования, и правила из конфигов объединяются в итоговый набор, который и применяется к проекту.

private func validate() throws -> [(configurationDict: [String: Any], rootDirectory: String)] {
    // Detect cycles via back-edge detection during DFS
    func walkDown(stack: [Vertex]) throws {
        // Please note that the equality check (`==`), not the identity check (`===`) is used
        let children = edges.filter { $0.parent == stack.last }.map { $0.child! }
        if stack.contains(where: children.contains) {
            throw Issue.genericWarning(
                "There's a cycle of child / parent config references. "
                + "Please check the hierarchy of configuration files passed via the command line "
                + "and the childConfig / parentConfig entries within them."
            )
        }
        try children.forEach { try walkDown(stack: stack + [$0]) }
    }

    try vertices.forEach { try walkDown(stack: [$0]) }

    // Detect ambiguities
    if edges.contains({ edge in edges.filter { $0.parent == edge.parent }.count > 1 }) {
        throw Issue.genericWarning(
            "There's an ambiguity in the child / parent configuration tree: "
            + "More than one parent is declared for a specific configuration, "
            + "where there should only be exactly one."
        )
    }

    if edges.contains({ edge in edges.filter { $0.child == edge.child }.count > 1 }) {
        throw Issue.genericWarning(
            "There's an ambiguity in the child / parent configuration tree: "
            + "More than one child is declared for a specific configuration, "
            + "where there should only be exactly one."
        )
    }
}
} catch {
    if case Issue.initialFileNotFound = error, !hasCustomConfigurationFiles {
        // The initial configuration file wasn't found, but the user didn't explicitly specify one
        // Don't handle as error. Instead, silently fall back to default.
        self.init(rulesMode: rulesMode, cachePath: cachePath)
        return
    }
    if useDefaultConfigOnFailure ?? !hasCustomConfigurationFiles {
        // No files were explicitly specified, so maybe the user doesn't want a config at all -> warn
        queuedPrintError(
            "\(Issue.wrap(error: error).localizedDescription) – Falling back to default configuration"
        )
        self.init(rulesMode: rulesMode, cachePath: cachePath)
    } else {
        // Files that were explicitly specified could not be loaded -> fail
        queuedPrintError(Issue.wrap(error: error).asError.localizedDescription)
        queuedFatalError("Could not read configuration")
    }
}

Классификация правил и иерархия конфигов

Следующий важный момент — это классификация правил. Когда правил десятки и сотни, без структуры работать тяжело. Поэтому мы систематизировали правила и выделили четыре уровня:

  1. Функциональные правила — влияют на логику и читаемость кода. Например, запреты на force unwrap или контроль использования print.

  2. Стилистические правила — отвечают за единый визуальный облик кода: пробелы, длина строк, позиция скобок, пустые строки.

  3. Архитектурные правила — помогают соблюдать общую структуру проекта и обеспечивать согласованность на уровне модулей и слоев. Например, правила именования публичных интерфейсов.

  4. Командно‑специфические правила — кастомные проверки под конкретный проект или доменную область.

Какую пользу получили:

  • легче объяснять, зачем нужно конкретное правило;

  • проще назначать ответственных за разные блоки;

  • можно централизованно управлять включением и отключением групп правил целиком.

Иерархия конфигов

На основе классификации мы выстроили иерархию конфигураций:

  • Глобальный конфиг — хранит корпоративные стандарты и обязательные правила. Здесь же определен раздел custom_rules под требования компании, например, комментарии только FIXME, а не TODO, вместо print — внутренний логгер.

# global-swiftlint.yml
disabled_rules:
  - anyobject_protocol
  - opening_brace
  ...

opt_in_rules:
  - array_init
  - attributes
  - closure_end_indentation
  ...

todo_comment:
  regex: "TODO:"
  message: "Используй FIXME вместо TODO"
  severity: warning

company_print_check:
  regex: "\\bprint\\("
  message: "Запрещен print(), используйте Logger"
  severity: error

reporter: "xcode"
  • Проектные конфиги — описывают локальные особенности. В одном проекте мы исключаем ненужные папки из проверки, в другом добавляем кастомные правила, уникальные для конкретной доменной области.

# app-swiftlint.yml
parent_config: "https://.../global-swiftlint.yml/..."

# Настройка таймаутов
remote_timeout: 30
remote_timeout_if_cached: 15

excluded:
  - Tests
  • Промежуточные уровни — если нужно, можно добавить слой для подразделения или команды. Так каждая команда расширяет базовый конфиг.

# sdk-swiftlint.yml
parent_config: "https://.../global-swiftlint.yml/..."

# Настройка таймаутов
remote_timeout: 30
remote_timeout_if_cached: 15

excluded:
  - Tests

# Специфичные правила проекта
custom_rules:
  public_api_prefix:
    included: ".*\\.swift"
    regex: "^(public (class|struct) (?!N))"
    message: "API классы SDK должны начинаться на 'N'"
    severity: error

В результате общие настройки вынесены в одно место, дубли исчезли, а чтобы изменить правило для всех проектов, достаточно обновить один базовый конфиг и изменения подтянутся во все репозитории.

Где хранить конфиги

Мы остановились на self‑hosted GitLab: он развернут внутри компании, с правами доступа и привычными процессами. Не нужно открывать внешние ресурсы или держать отдельный сервер — все управляется в единой экосистеме.

Что особенно удобно — GitLab API позволяет отдавать файлы напрямую по REST‑запросу. Мы можем получить конкретную версию файла из ветки, тега или коммита — для этого используется параметр ref. Это дает гибкость, которой обычно не хватает в подобных решениях: можно спокойно вносить изменения и проверять их, прежде чем интегрировать в основную ветку.

Благодаря этому удаленная конфигурация превращается не просто в общий YAML‑файл, а в полноценный версионируемый артефакт, который можно менять, тестировать и катить в прод по такой же логике, как и обычный код.

https://gitlab.company.com/api/v4/projects/123/

repository/files/.swiftlint.yml/raw?ref=main&private\_token=<ваш токен>

  • адрес GitLab-инстанса: gitlab.company.com

  • идентификатор проекта: 123

  • имя файла: .swiftlint.yml

  • параметр ref: main

  • токен доступа: gplat-647jhfsg8sfh

Две проблемы, с которыми мы столкнулись

Как обычно, все красиво, пока не доходишь до реального внедрения. И тут мечты о том, как все заработает с первого раза, разбиваются о суровую реальность продакшена.

Проблема 1. SwiftLint не подставляет env-переменные в URL

SwiftLint не умеет подставлять переменные окружения в URL‑ссылку. Поэтому просто взять и передать токен доступа через переменную окружения не получится.

Чтобы обойти это ограничение, мы написали небольшой bash‑скрипт, где все происходит автоматически. Он делает три простых шага:

  1. Через envsubst подставляет токен доступа и генерирует временный конфиг с токеном в ссылке.

  2. Запускает SwiftLint с этим конфигом.

  3. Удаляет временный файл после завершения работы, чтобы не хранить чувствительные данные на диске.

Проблема 2. Тысячи ошибок на первом запуске

Когда мы впервые запустили линтер на существующем коде, он выдал тысячи ошибок и предупреждений. С такой лавиной бороться бессмысленно — команда очень быстро перестает воспринимать отчеты линтера всерьез.

После обсуждения в команде мы решили использовать функциональность baseline:

  • один раз записали все текущие нарушения в базовый файл;

  • дальше SwiftLint подсвечивает только новые проблемы — те, что появились в измененных строках кода.

Таким образом, мы не захламляем логи Xcode, не теряем продуктивность и постепенно очищаем проект от предупреждений по мере работы над новыми задачами.

Альтернативы: Detekt, Gradle и SonarQube

Я посмотрел и на альтернативы. В Android‑мире есть Detekt, но встроенной возможности загружать конфиг по ссылке нет. Зато есть Gradle — с его помощью действительно можно собрать похожую логику в пару строк. Но по факту это все равно код, который нужно писать, тестировать и поддерживать.

Потом я посмотрел шире — на SonarQube. Это уже более универсальная платформа статического анализа: десятки языков, включая Swift, метрики качества, дублирование кода, покрытие тестами, безопасность и отчеты, собранные в одном месте. Наши бэкенд‑разработчики давно используют SonarQube, и идея унифицировать инструменты внутри компании звучит логично.

Но пока мы остановились на SwiftLint, потому что:

  • он активно развивается и хорошо поддерживается сообществом;

  • он прост в настройке и привычен iOS‑разработчикам;

  • в нем удобно писать кастомные правила на регулярках — это сильно расширяет возможности под нужды команды.

Для нас SwiftLint стал пилотным решением. И если оно успешно масштабируется внутри мобильной команды, мы получим не просто настроенный инструмент, а методологию централизованного управления кодстайлом. А значит, будем готовы перейти на более мощные платформы вроде SonarQube, уже понимая, как выстроить процесс.

Что получилось в итоге

После внедрения Remote Config мы получили несколько ощутимых результатов:

  • Единые правила кодстайла во всех проектах. Команда работает по одним стандартам, меньше разночтений и «локальных традиций».

  • Автоматическая синхронизация правил. Любые изменения в главной конфигурации автоматически подтягиваются во все репозитории — команды всегда работают по актуальным стандартам.

  • Более быстрое и прозрачное код‑ревью. Линтер забирает на себя проверку стиля и формата, ревьюеры концентрируются на логике и архитектуре.

  • Упрощенный онбординг. Новым разработчикам не нужно искать «как принято» по вики‑страницам и старым документам — правила применяются автоматически при первом запуске проекта.

И главное — линтер стал частью инфраструктуры, которая помогает поддерживать качество кода без лишних усилий.

Remote Configs — малоизвестная фича SwiftLint, которая превращает набор разрозненных конфигов в живую систему управления качеством. Если у вас несколько проектов — попробуйте вынести базовую конфигурацию в одно место. Линтер сам доставит правила куда нужно. А вы сможете сосредоточиться не на запятых и пробелах, а на коде и архитектуре.

Комментарии (0)