chiark / gitweb /
lint: exit with an error code if any errors are found
[fdroidserver.git] / fdroidserver / lint.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # lint.py - part of the FDroid server tool
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See th
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public Licen
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 from optparse import OptionParser
21 import re
22 import logging
23 import common
24 import metadata
25 import sys
26 from collections import Counter
27 from sets import Set
28
29 config = None
30 options = None
31
32
33 def enforce_https(domain):
34     return (re.compile(r'.*[^sS]://[^/]*' + re.escape(domain) + r'/.*'),
35             domain + " URLs should always use https://")
36
37 https_enforcings = [
38     enforce_https('github.com'),
39     enforce_https('gitlab.com'),
40     enforce_https('gitorious.org'),
41     enforce_https('apache.org'),
42     enforce_https('google.com'),
43     enforce_https('svn.code.sf.net'),
44     enforce_https('googlecode.com'),
45 ]
46
47 http_warnings = https_enforcings + [
48     (re.compile(r'.*github\.com/[^/]+/[^/]+\.git'),
49      "Appending .git is not necessary"),
50     # TODO enable in August 2015, when Google Code goes read-only
51     # (re.compile(r'.*://code\.google\.com/.*'),
52     #  "code.google.com will be soon switching down, perhaps the project moved to github.com?"),
53 ]
54
55 regex_warnings = {
56     'Web Site': http_warnings + [
57     ],
58     'Source Code': http_warnings + [
59     ],
60     'Repo': https_enforcings + [
61     ],
62     'Issue Tracker': http_warnings + [
63         (re.compile(r'.*github\.com/[^/]+/[^/]+[/]*$'),
64          "/issues is missing"),
65     ],
66     'Changelog': http_warnings + [
67     ],
68     'License': [
69         (re.compile(r'^(|None|Unknown)$'),
70          "No license specified"),
71     ],
72     'Summary': [
73         (re.compile(r'^$'),
74          "Summary yet to be filled"),
75         (re.compile(r'.*\b(free software|open source)\b.*', re.IGNORECASE),
76          "No need to specify that the app is Free Software"),
77         (re.compile(r'.*((your|for).*android|android.*(app|device|client|port|version))', re.IGNORECASE),
78          "No need to specify that the app is for Android"),
79         (re.compile(r'.*[a-z0-9][.!?]( |$)'),
80          "Punctuation should be avoided"),
81     ],
82     'Description': [
83         (re.compile(r'^No description available$'),
84          "Description yet to be filled"),
85         (re.compile(r'\s*[*#][^ .]'),
86          "Invalid bulleted list"),
87         (re.compile(r'^\s'),
88          "Unnecessary leading space"),
89         (re.compile(r'.*\s$'),
90          "Unnecessary trailing space"),
91     ],
92 }
93
94 categories = Set([
95     "Children",
96     "Development",
97     "Games",
98     "Internet",
99     "Multimedia",
100     "Navigation",
101     "Office",
102     "Phone & SMS",
103     "Reading",
104     "Science & Education",
105     "Security",
106     "System",
107     "Wallpaper",
108 ])
109
110
111 def main():
112
113     global config, options, curid, count
114     curid = None
115
116     count = Counter()
117
118     def warn(message):
119         global curid, count
120         if curid:
121             print "%s:" % curid
122             curid = None
123             count['app'] += 1
124         print '    %s' % message
125         count['warn'] += 1
126
127     # Parse command line...
128     parser = OptionParser(usage="Usage: %prog [options] [APPID [APPID ...]]")
129     parser.add_option("-v", "--verbose", action="store_true", default=False,
130                       help="Spew out even more information than normal")
131     parser.add_option("-q", "--quiet", action="store_true", default=False,
132                       help="Restrict output to warnings and errors")
133     (options, args) = parser.parse_args()
134
135     config = common.read_config(options)
136
137     # Get all apps...
138     allapps = metadata.read_metadata(xref=False)
139     apps = common.read_app_args(args, allapps, False)
140
141     for appid, app in apps.iteritems():
142         if app['Disabled']:
143             continue
144
145         curid = appid
146         count['app_total'] += 1
147
148         curbuild = None
149         for build in app['builds']:
150             if not curbuild or int(build['vercode']) > int(curbuild['vercode']):
151                 curbuild = build
152
153         # Incorrect UCM
154         if (curbuild and curbuild['commit']
155                 and app['Update Check Mode'] == 'RepoManifest'
156                 and not curbuild['commit'].startswith('unknown')
157                 and curbuild['vercode'] == app['Current Version Code']
158                 and not curbuild['forcevercode']
159                 and any(s in curbuild['commit'] for s in '.,_-/')):
160             warn("Last used commit '%s' looks like a tag, but Update Check Mode is '%s'" % (
161                 curbuild['commit'], app['Update Check Mode']))
162
163         # Summary size limit
164         summ_chars = len(app['Summary'])
165         if summ_chars > config['char_limits']['Summary']:
166             warn("Summary of length %s is over the %i char limit" % (
167                 summ_chars, config['char_limits']['Summary']))
168
169         # Redundant info
170         if app['Web Site'] and app['Source Code']:
171             if app['Web Site'].lower() == app['Source Code'].lower():
172                 warn("Website '%s' is just the app's source code link" % app['Web Site'])
173
174         # Missing or incorrect categories
175         if not app['Categories']:
176             warn("Categories are not set")
177         for categ in app['Categories']:
178             if categ not in categories:
179                 warn("Category '%s' is not valid" % categ)
180
181         if app['Name'] and app['Name'] == app['Auto Name']:
182             warn("Name '%s' is just the auto name" % app['Name'])
183
184         name = app['Name'] or app['Auto Name']
185         if app['Summary'] and name:
186             if app['Summary'].lower() == name.lower():
187                 warn("Summary '%s' is just the app's name" % app['Summary'])
188
189         desc = app['Description']
190         if app['Summary'] and desc and len(desc) == 1:
191             if app['Summary'].lower() == desc[0].lower():
192                 warn("Description '%s' is just the app's summary" % app['Summary'])
193
194         # Description size limit
195         desc_charcount = sum(len(l) for l in desc)
196         if desc_charcount > config['char_limits']['Description']:
197             warn("Description of length %s is over the %i char limit" % (
198                 desc_charcount, config['char_limits']['Description']))
199
200         if (not desc[0] or not desc[-1]
201                 or any(not desc[l - 1] and not desc[l] for l in range(1, len(desc)))):
202             warn("Description has an extra empty line")
203
204         # Regex checks in all kinds of fields
205         for f in regex_warnings:
206             for m, r in regex_warnings[f]:
207                 t = metadata.metafieldtype(f)
208                 if t == 'string':
209                     if m.match(app[f]):
210                         warn("%s '%s': %s" % (f, app[f], r))
211                 elif t == 'multiline':
212                     for l in app[f]:
213                         if m.match(l):
214                             warn("%s at line '%s': %s" % (f, l, r))
215
216         # Build warnings
217         for build in app['builds']:
218             if build['disable']:
219                 continue
220             for s in ['master', 'origin', 'HEAD', 'default', 'trunk']:
221                 if build['commit'] and build['commit'].startswith(s):
222                     warn("Branch '%s' used as commit in build '%s'" % (
223                         s, build['version']))
224                 for srclib in build['srclibs']:
225                     ref = srclib.split('@')[1].split('/')[0]
226                     if ref.startswith(s):
227                         warn("Branch '%s' used as commit in srclib '%s'" % (
228                             s, srclib))
229
230         if not curid:
231             print
232
233     logging.info("Found a total of %i warnings in %i apps out of %i total." % (
234         count['warn'], count['app'], count['app_total']))
235
236     sys.exit(1 if count['warn'] > 0 else 0)
237
238 if __name__ == "__main__":
239     main()