blob: 4696efd1360507317def114127f3c681060df3b5 [file] [log] [blame]
Mårten Kongstad90087242024-04-16 09:55:56 +02001/*
2 * Copyright (C) 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16@file:JvmName("Main")
17
18package com.android.checkflaggedapis
19
Mårten Kongstad387ff6c2024-04-16 12:42:14 +020020import android.aconfig.Aconfig
Mårten Kongstad20de4052024-04-16 11:33:56 +020021import com.android.tools.metalava.model.BaseItemVisitor
Mårten Kongstad18ff19a2024-04-26 05:48:57 +020022import com.android.tools.metalava.model.ClassItem
Mårten Kongstad20de4052024-04-16 11:33:56 +020023import com.android.tools.metalava.model.FieldItem
Mårten Kongstad18ff19a2024-04-26 05:48:57 +020024import com.android.tools.metalava.model.Item
Mårten Kongstad40da9702024-04-27 01:42:51 +020025import com.android.tools.metalava.model.MethodItem
Mårten Kongstad20de4052024-04-16 11:33:56 +020026import com.android.tools.metalava.model.text.ApiFile
Mårten Kongstadacfeb112024-04-16 10:30:26 +020027import com.github.ajalt.clikt.core.CliktCommand
28import com.github.ajalt.clikt.core.ProgramResult
Mårten Kongstadd5ce20f2024-06-10 13:59:46 +020029import com.github.ajalt.clikt.core.subcommands
Mårten Kongstad20de4052024-04-16 11:33:56 +020030import com.github.ajalt.clikt.parameters.options.help
31import com.github.ajalt.clikt.parameters.options.option
32import com.github.ajalt.clikt.parameters.options.required
33import com.github.ajalt.clikt.parameters.types.path
34import java.io.InputStream
Mårten Kongstadb673d3b2024-04-16 18:34:20 +020035import javax.xml.parsers.DocumentBuilderFactory
36import org.w3c.dom.Node
Mårten Kongstadacfeb112024-04-16 10:30:26 +020037
Mårten Kongstade0179972024-04-16 11:16:44 +020038/**
39 * Class representing the fully qualified name of a class, method or field.
40 *
41 * This tool reads a multitude of input formats all of which represents the fully qualified path to
42 * a Java symbol slightly differently. To keep things consistent, all parsed APIs are converted to
43 * Symbols.
44 *
Mårten Kongstadece054c2024-05-02 09:45:11 +020045 * Symbols are encoded using the format similar to the one described in section 4.3.2 of the JVM
46 * spec [1], that is, "package.class.inner-class.method(int, int[], android.util.Clazz)" is
47 * represented as
Mårten Kongstade0179972024-04-16 11:16:44 +020048 * <pre>
Mårten Kongstadece054c2024-05-02 09:45:11 +020049 * package.class.inner-class.method(II[Landroid/util/Clazz;)
50 * <pre>
51 *
52 * Where possible, the format has been simplified (to make translation of the
53 * various input formats easier): for instance, only / is used as delimiter (#
54 * and $ are never used).
55 *
56 * 1. https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.3.2
Mårten Kongstade0179972024-04-16 11:16:44 +020057 */
Mårten Kongstada1fe3712024-05-06 13:46:21 +020058internal sealed class Symbol {
Mårten Kongstade0179972024-04-16 11:16:44 +020059 companion object {
Mårten Kongstadece054c2024-05-02 09:45:11 +020060 private val FORBIDDEN_CHARS = listOf('#', '$', '.')
Mårten Kongstade0179972024-04-16 11:16:44 +020061
Mårten Kongstadc3f05a62024-05-06 21:42:15 +020062 fun createClass(clazz: String, superclass: String?, interfaces: Set<String>): Symbol {
63 return ClassSymbol(
64 toInternalFormat(clazz),
65 superclass?.let { toInternalFormat(it) },
66 interfaces.map { toInternalFormat(it) }.toSet())
Mårten Kongstada1fe3712024-05-06 13:46:21 +020067 }
68
69 fun createField(clazz: String, field: String): Symbol {
70 require(!field.contains("(") && !field.contains(")"))
71 return MemberSymbol(toInternalFormat(clazz), toInternalFormat(field))
72 }
73
74 fun createMethod(clazz: String, method: String): Symbol {
75 return MemberSymbol(toInternalFormat(clazz), toInternalFormat(method))
76 }
77
78 protected fun toInternalFormat(name: String): String {
79 var internalName = name
Mårten Kongstade0179972024-04-16 11:16:44 +020080 for (ch in FORBIDDEN_CHARS) {
Mårten Kongstada1fe3712024-05-06 13:46:21 +020081 internalName = internalName.replace(ch, '/')
Mårten Kongstade0179972024-04-16 11:16:44 +020082 }
Mårten Kongstada1fe3712024-05-06 13:46:21 +020083 return internalName
Mårten Kongstade0179972024-04-16 11:16:44 +020084 }
85 }
86
Mårten Kongstada1fe3712024-05-06 13:46:21 +020087 abstract fun toPrettyString(): String
88}
Mårten Kongstade0179972024-04-16 11:16:44 +020089
Mårten Kongstadc3f05a62024-05-06 21:42:15 +020090internal data class ClassSymbol(
91 val clazz: String,
92 val superclass: String?,
93 val interfaces: Set<String>
94) : Symbol() {
Mårten Kongstada1fe3712024-05-06 13:46:21 +020095 override fun toPrettyString(): String = "$clazz"
96}
97
98internal data class MemberSymbol(val clazz: String, val member: String) : Symbol() {
99 override fun toPrettyString(): String = "$clazz/$member"
Mårten Kongstade0179972024-04-16 11:16:44 +0200100}
101
Mårten Kongstaddc3fc2e2024-04-16 11:23:22 +0200102/**
103 * Class representing the fully qualified name of an aconfig flag.
104 *
105 * This includes both the flag's package and name, separated by a dot, e.g.:
106 * <pre>
107 * com.android.aconfig.test.disabled_ro
108 * <pre>
109 */
110@JvmInline
111internal value class Flag(val name: String) {
112 override fun toString(): String = name.toString()
113}
114
Mårten Kongstad9238a3a2024-04-16 13:19:50 +0200115internal sealed class ApiError {
116 abstract val symbol: Symbol
117 abstract val flag: Flag
118}
119
120internal data class EnabledFlaggedApiNotPresentError(
121 override val symbol: Symbol,
122 override val flag: Flag
123) : ApiError() {
124 override fun toString(): String {
Mårten Kongstada1fe3712024-05-06 13:46:21 +0200125 return "error: enabled @FlaggedApi not present in built artifact: symbol=${symbol.toPrettyString()} flag=$flag"
Mårten Kongstad9238a3a2024-04-16 13:19:50 +0200126 }
127}
128
129internal data class DisabledFlaggedApiIsPresentError(
130 override val symbol: Symbol,
131 override val flag: Flag
132) : ApiError() {
133 override fun toString(): String {
Mårten Kongstada1fe3712024-05-06 13:46:21 +0200134 return "error: disabled @FlaggedApi is present in built artifact: symbol=${symbol.toPrettyString()} flag=$flag"
Mårten Kongstad9238a3a2024-04-16 13:19:50 +0200135 }
136}
137
138internal data class UnknownFlagError(override val symbol: Symbol, override val flag: Flag) :
139 ApiError() {
140 override fun toString(): String {
Mårten Kongstada1fe3712024-05-06 13:46:21 +0200141 return "error: unknown flag: symbol=${symbol.toPrettyString()} flag=$flag"
Mårten Kongstad9238a3a2024-04-16 13:19:50 +0200142 }
143}
144
Mårten Kongstad576e8182024-06-10 15:33:12 +0200145val ARG_API_SIGNATURE = "--api-signature"
146val ARG_API_SIGNATURE_HELP =
147 """
148Path to API signature file.
149Usually named *current.txt.
150Tip: `m frameworks-base-api-current.txt` will generate a file that includes all platform and mainline APIs.
151"""
152
153val ARG_FLAG_VALUES = "--flag-values"
154val ARG_FLAG_VALUES_HELP =
155 """
156Path to aconfig parsed_flags binary proto file.
157Tip: `m all_aconfig_declarations` will generate a file that includes all information about all flags.
158"""
159
160val ARG_API_VERSIONS = "--api-versions"
161val ARG_API_VERSIONS_HELP =
162 """
163Path to API versions XML file.
164Usually named xml-versions.xml.
165Tip: `m sdk dist` will generate a file that includes all platform and mainline APIs.
166"""
167
Mårten Kongstadd5ce20f2024-06-10 13:59:46 +0200168class MainCommand : CliktCommand() {
169 override fun run() {}
170}
171
Mårten Kongstad20de4052024-04-16 11:33:56 +0200172class CheckCommand :
173 CliktCommand(
174 help =
175 """
176Check that all flagged APIs are used in the correct way.
177
178This tool reads the API signature file and checks that all flagged APIs are used in the correct way.
179
180The tool will exit with a non-zero exit code if any flagged APIs are found to be used in the incorrect way.
181""") {
182 private val apiSignaturePath by
Mårten Kongstad576e8182024-06-10 15:33:12 +0200183 option(ARG_API_SIGNATURE)
184 .help(ARG_API_SIGNATURE_HELP)
Mårten Kongstad20de4052024-04-16 11:33:56 +0200185 .path(mustExist = true, canBeDir = false, mustBeReadable = true)
186 .required()
Mårten Kongstad387ff6c2024-04-16 12:42:14 +0200187 private val flagValuesPath by
Mårten Kongstad576e8182024-06-10 15:33:12 +0200188 option(ARG_FLAG_VALUES)
189 .help(ARG_FLAG_VALUES_HELP)
Mårten Kongstad387ff6c2024-04-16 12:42:14 +0200190 .path(mustExist = true, canBeDir = false, mustBeReadable = true)
191 .required()
Mårten Kongstadb673d3b2024-04-16 18:34:20 +0200192 private val apiVersionsPath by
Mårten Kongstad576e8182024-06-10 15:33:12 +0200193 option(ARG_API_VERSIONS)
194 .help(ARG_API_VERSIONS_HELP)
Mårten Kongstadb673d3b2024-04-16 18:34:20 +0200195 .path(mustExist = true, canBeDir = false, mustBeReadable = true)
196 .required()
Mårten Kongstad20de4052024-04-16 11:33:56 +0200197
Mårten Kongstadacfeb112024-04-16 10:30:26 +0200198 override fun run() {
Mårten Kongstad20de4052024-04-16 11:33:56 +0200199 val flaggedSymbols =
200 apiSignaturePath.toFile().inputStream().use {
201 parseApiSignature(apiSignaturePath.toString(), it)
202 }
Mårten Kongstad387ff6c2024-04-16 12:42:14 +0200203 val flags = flagValuesPath.toFile().inputStream().use { parseFlagValues(it) }
Mårten Kongstadb673d3b2024-04-16 18:34:20 +0200204 val exportedSymbols = apiVersionsPath.toFile().inputStream().use { parseApiVersions(it) }
Mårten Kongstad9238a3a2024-04-16 13:19:50 +0200205 val errors = findErrors(flaggedSymbols, flags, exportedSymbols)
206 for (e in errors) {
207 println(e)
208 }
209 throw ProgramResult(errors.size)
Mårten Kongstadacfeb112024-04-16 10:30:26 +0200210 }
211}
212
Mårten Kongstad20de4052024-04-16 11:33:56 +0200213internal fun parseApiSignature(path: String, input: InputStream): Set<Pair<Symbol, Flag>> {
Mårten Kongstad20de4052024-04-16 11:33:56 +0200214 val output = mutableSetOf<Pair<Symbol, Flag>>()
215 val visitor =
216 object : BaseItemVisitor() {
Mårten Kongstad18ff19a2024-04-26 05:48:57 +0200217 override fun visitClass(cls: ClassItem) {
218 getFlagOrNull(cls)?.let { flag ->
Mårten Kongstad7c3571f2024-05-06 14:53:54 +0200219 val symbol =
220 Symbol.createClass(
221 cls.baselineElementId(),
Mårten Kongstadaa41dac2024-05-22 15:13:54 +0200222 if (cls.isInterface()) {
223 "java/lang/Object"
224 } else {
225 cls.superClass()?.baselineElementId()
226 },
Mårten Kongstad04d8b462024-05-06 16:26:40 +0200227 cls.allInterfaces()
228 .map { it.baselineElementId() }
229 .filter { it != cls.baselineElementId() }
230 .toSet())
Mårten Kongstad18ff19a2024-04-26 05:48:57 +0200231 output.add(Pair(symbol, flag))
Mårten Kongstad20de4052024-04-16 11:33:56 +0200232 }
233 }
Mårten Kongstad18ff19a2024-04-26 05:48:57 +0200234
235 override fun visitField(field: FieldItem) {
236 getFlagOrNull(field)?.let { flag ->
Mårten Kongstada1fe3712024-05-06 13:46:21 +0200237 val symbol =
238 Symbol.createField(field.containingClass().baselineElementId(), field.name())
Mårten Kongstad18ff19a2024-04-26 05:48:57 +0200239 output.add(Pair(symbol, flag))
240 }
241 }
242
Mårten Kongstad40da9702024-04-27 01:42:51 +0200243 override fun visitMethod(method: MethodItem) {
244 getFlagOrNull(method)?.let { flag ->
Mårten Kongstada1fe3712024-05-06 13:46:21 +0200245 val methodName = buildString {
Mårten Kongstad40da9702024-04-27 01:42:51 +0200246 append(method.name())
247 append("(")
Mårten Kongstadb4a14bf2024-04-28 00:21:11 +0200248 method.parameters().joinTo(this, separator = "") { it.type().internalName() }
Mårten Kongstad40da9702024-04-27 01:42:51 +0200249 append(")")
250 }
Mårten Kongstada1fe3712024-05-06 13:46:21 +0200251 val symbol = Symbol.createMethod(method.containingClass().qualifiedName(), methodName)
Mårten Kongstad40da9702024-04-27 01:42:51 +0200252 output.add(Pair(symbol, flag))
253 }
254 }
255
Mårten Kongstad18ff19a2024-04-26 05:48:57 +0200256 private fun getFlagOrNull(item: Item): Flag? {
257 return item.modifiers
258 .findAnnotation("android.annotation.FlaggedApi")
259 ?.findAttribute("value")
260 ?.value
261 ?.let { Flag(it.value() as String) }
262 }
Mårten Kongstad20de4052024-04-16 11:33:56 +0200263 }
264 val codebase = ApiFile.parseApi(path, input)
265 codebase.accept(visitor)
266 return output
267}
268
Mårten Kongstad387ff6c2024-04-16 12:42:14 +0200269internal fun parseFlagValues(input: InputStream): Map<Flag, Boolean> {
270 val parsedFlags = Aconfig.parsed_flags.parseFrom(input).getParsedFlagList()
271 return parsedFlags.associateBy(
272 { Flag("${it.getPackage()}.${it.getName()}") },
273 { it.getState() == Aconfig.flag_state.ENABLED })
274}
275
Mårten Kongstadb673d3b2024-04-16 18:34:20 +0200276internal fun parseApiVersions(input: InputStream): Set<Symbol> {
277 fun Node.getAttribute(name: String): String? = getAttributes()?.getNamedItem(name)?.getNodeValue()
278
279 val output = mutableSetOf<Symbol>()
280 val factory = DocumentBuilderFactory.newInstance()
281 val parser = factory.newDocumentBuilder()
282 val document = parser.parse(input)
Mårten Kongstad18ff19a2024-04-26 05:48:57 +0200283
284 val classes = document.getElementsByTagName("class")
285 // ktfmt doesn't understand the `..<` range syntax; explicitly call .rangeUntil instead
286 for (i in 0.rangeUntil(classes.getLength())) {
287 val cls = classes.item(i)
288 val className =
289 requireNotNull(cls.getAttribute("name")) {
290 "Bad XML: <class> element without name attribute"
291 }
Mårten Kongstadc3f05a62024-05-06 21:42:15 +0200292 var superclass: String? = null
Mårten Kongstad7c3571f2024-05-06 14:53:54 +0200293 val interfaces = mutableSetOf<String>()
294 val children = cls.getChildNodes()
295 for (j in 0.rangeUntil(children.getLength())) {
296 val child = children.item(j)
Mårten Kongstadc3f05a62024-05-06 21:42:15 +0200297 when (child.getNodeName()) {
298 "extends" -> {
299 superclass =
300 requireNotNull(child.getAttribute("name")) {
301 "Bad XML: <extends> element without name attribute"
302 }
303 }
304 "implements" -> {
305 val interfaceName =
306 requireNotNull(child.getAttribute("name")) {
307 "Bad XML: <implements> element without name attribute"
308 }
309 interfaces.add(interfaceName)
310 }
Mårten Kongstad7c3571f2024-05-06 14:53:54 +0200311 }
312 }
Mårten Kongstadc3f05a62024-05-06 21:42:15 +0200313 output.add(Symbol.createClass(className, superclass, interfaces))
Mårten Kongstad18ff19a2024-04-26 05:48:57 +0200314 }
315
Mårten Kongstadb673d3b2024-04-16 18:34:20 +0200316 val fields = document.getElementsByTagName("field")
317 // ktfmt doesn't understand the `..<` range syntax; explicitly call .rangeUntil instead
318 for (i in 0.rangeUntil(fields.getLength())) {
319 val field = fields.item(i)
Mårten Kongstad04e45642024-04-26 05:39:03 +0200320 val fieldName =
321 requireNotNull(field.getAttribute("name")) {
322 "Bad XML: <field> element without name attribute"
323 }
Mårten Kongstadb673d3b2024-04-16 18:34:20 +0200324 val className =
Mårten Kongstadece054c2024-05-02 09:45:11 +0200325 requireNotNull(field.getParentNode()?.getAttribute("name")) {
326 "Bad XML: top level <field> element"
327 }
Mårten Kongstada1fe3712024-05-06 13:46:21 +0200328 output.add(Symbol.createField(className, fieldName))
Mårten Kongstadb673d3b2024-04-16 18:34:20 +0200329 }
Mårten Kongstad18ff19a2024-04-26 05:48:57 +0200330
Mårten Kongstad40da9702024-04-27 01:42:51 +0200331 val methods = document.getElementsByTagName("method")
332 // ktfmt doesn't understand the `..<` range syntax; explicitly call .rangeUntil instead
333 for (i in 0.rangeUntil(methods.getLength())) {
334 val method = methods.item(i)
335 val methodSignature =
336 requireNotNull(method.getAttribute("name")) {
337 "Bad XML: <method> element without name attribute"
338 }
339 val methodSignatureParts = methodSignature.split(Regex("\\(|\\)"))
340 if (methodSignatureParts.size != 3) {
Mårten Kongstad9aef0d92024-04-29 10:25:34 +0200341 throw Exception("Bad XML: method signature '$methodSignature'")
Mårten Kongstad40da9702024-04-27 01:42:51 +0200342 }
Mårten Kongstadcd93aeb2024-05-02 10:19:18 +0200343 var (methodName, methodArgs, _) = methodSignatureParts
Mårten Kongstad40da9702024-04-27 01:42:51 +0200344 val packageAndClassName =
345 requireNotNull(method.getParentNode()?.getAttribute("name")) {
Mårten Kongstad02525a82024-05-06 10:28:02 +0200346 "Bad XML: top level <method> element, or <class> element missing name attribute"
347 }
348 .replace("$", "/")
Mårten Kongstad40da9702024-04-27 01:42:51 +0200349 if (methodName == "<init>") {
350 methodName = packageAndClassName.split("/").last()
351 }
Mårten Kongstada1fe3712024-05-06 13:46:21 +0200352 output.add(Symbol.createMethod(packageAndClassName, "$methodName($methodArgs)"))
Mårten Kongstad40da9702024-04-27 01:42:51 +0200353 }
354
Mårten Kongstadb673d3b2024-04-16 18:34:20 +0200355 return output
356}
357
Mårten Kongstad9238a3a2024-04-16 13:19:50 +0200358/**
359 * Find errors in the given data.
360 *
361 * @param flaggedSymbolsInSource the set of symbols that are flagged in the source code
362 * @param flags the set of flags and their values
363 * @param symbolsInOutput the set of symbols that are present in the output
364 * @return the set of errors found
365 */
366internal fun findErrors(
367 flaggedSymbolsInSource: Set<Pair<Symbol, Flag>>,
368 flags: Map<Flag, Boolean>,
369 symbolsInOutput: Set<Symbol>
370): Set<ApiError> {
Mårten Kongstadd2c70762024-05-06 14:58:18 +0200371 fun Set<Symbol>.containsSymbol(symbol: Symbol): Boolean {
372 // trivial case: the symbol is explicitly listed in api-versions.xml
373 if (contains(symbol)) {
374 return true
375 }
376
377 // non-trivial case: the symbol could be part of the surrounding class'
378 // super class or interfaces
379 val (className, memberName) =
380 when (symbol) {
381 is ClassSymbol -> return false
382 is MemberSymbol -> {
383 Pair(symbol.clazz, symbol.member)
384 }
385 }
386 val clazz = find { it is ClassSymbol && it.clazz == className } as? ClassSymbol?
387 if (clazz == null) {
388 return false
389 }
390
391 for (interfaceName in clazz.interfaces) {
392 // createMethod is the same as createField, except it allows parenthesis
393 val interfaceSymbol = Symbol.createMethod(interfaceName, memberName)
394 if (contains(interfaceSymbol)) {
395 return true
396 }
397 }
398
Mårten Kongstade8120392024-05-06 21:32:34 +0200399 if (clazz.superclass != null) {
400 val superclassSymbol = Symbol.createMethod(clazz.superclass, memberName)
401 return containsSymbol(superclassSymbol)
402 }
403
Mårten Kongstadd2c70762024-05-06 14:58:18 +0200404 return false
405 }
Mårten Kongstad0d44e722024-05-08 10:00:32 +0200406
407 /**
408 * Returns whether the given flag is enabled for the given symbol.
409 *
410 * A flagged member inside a flagged class is ignored (and the flag value considered disabled) if
411 * the class' flag is disabled.
412 *
413 * @param symbol the symbol to check
414 * @param flag the flag to check
415 * @return whether the flag is enabled for the given symbol
416 */
417 fun isFlagEnabledForSymbol(symbol: Symbol, flag: Flag): Boolean {
418 when (symbol) {
419 is ClassSymbol -> return flags.getValue(flag)
420 is MemberSymbol -> {
421 val memberFlagValue = flags.getValue(flag)
422 if (!memberFlagValue) {
423 return false
424 }
425 // Special case: if the MemberSymbol's flag is enabled, but the outer
426 // ClassSymbol's flag (if the class is flagged) is disabled, consider
427 // the MemberSymbol's flag as disabled:
428 //
429 // @FlaggedApi(this-flag-is-disabled) Clazz {
430 // @FlaggedApi(this-flag-is-enabled) method(); // The Clazz' flag "wins"
431 // }
432 //
433 // Note: the current implementation does not handle nested classes.
434 val classFlagValue =
435 flaggedSymbolsInSource
436 .find { it.first.toPrettyString() == symbol.clazz }
437 ?.let { flags.getValue(it.second) }
438 ?: true
439 return classFlagValue
440 }
441 }
442 }
443
Mårten Kongstad9238a3a2024-04-16 13:19:50 +0200444 val errors = mutableSetOf<ApiError>()
445 for ((symbol, flag) in flaggedSymbolsInSource) {
446 try {
Mårten Kongstad0d44e722024-05-08 10:00:32 +0200447 if (isFlagEnabledForSymbol(symbol, flag)) {
Mårten Kongstadd2c70762024-05-06 14:58:18 +0200448 if (!symbolsInOutput.containsSymbol(symbol)) {
Mårten Kongstad9238a3a2024-04-16 13:19:50 +0200449 errors.add(EnabledFlaggedApiNotPresentError(symbol, flag))
450 }
451 } else {
Mårten Kongstadd2c70762024-05-06 14:58:18 +0200452 if (symbolsInOutput.containsSymbol(symbol)) {
Mårten Kongstad9238a3a2024-04-16 13:19:50 +0200453 errors.add(DisabledFlaggedApiIsPresentError(symbol, flag))
454 }
455 }
456 } catch (e: NoSuchElementException) {
457 errors.add(UnknownFlagError(symbol, flag))
458 }
459 }
460 return errors
461}
462
Mårten Kongstadd5ce20f2024-06-10 13:59:46 +0200463fun main(args: Array<String>) = MainCommand().subcommands(CheckCommand()).main(args)