chiark / gitweb /
lint: new category
[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('bitbucket.org'),
41     enforce_https('gitorious.org'),
42     enforce_https('apache.org'),
43     enforce_https('google.com'),
44     enforce_https('svn.code.sf.net'),
45     enforce_https('googlecode.com'),
46 ]
47
48
49 def forbid_shortener(domain):
50     return (re.compile(r'https?://[^/]*' + re.escape(domain) + r'/.*'),
51             "URL shorteners should not be used")
52
53 http_url_shorteners = [
54     forbid_shortener('goo.gl'),
55     forbid_shortener('t.co'),
56     forbid_shortener('ur1.ca'),
57 ]
58
59 http_warnings = https_enforcings + http_url_shorteners + [
60     (re.compile(r'.*github\.com/[^/]+/[^/]+\.git'),
61      "Appending .git is not necessary"),
62     (re.compile(r'(.*/blob/master/|.*raw\.github.com/[^/]*/[^/]*/master/)'),
63      "Use /HEAD/ instead of /master/ to point at a file in the default branch"),
64 ]
65
66 regex_warnings = {
67     'Web Site': http_warnings + [
68     ],
69     'Source Code': http_warnings + [
70     ],
71     'Repo': https_enforcings + [
72     ],
73     'Issue Tracker': http_warnings + [
74         (re.compile(r'.*github\.com/[^/]+/[^/]+[/]*$'),
75          "/issues is missing"),
76     ],
77     'Donate': http_warnings + [
78         (re.compile(r'.*flattr\.com'),
79          "Flattr donation methods belong in the FlattrID flag"),
80     ],
81     'Changelog': http_warnings + [
82     ],
83     'License': [
84         (re.compile(r'^(|None|Unknown)$'),
85          "No license specified"),
86     ],
87     'Summary': [
88         (re.compile(r'^$'),
89          "Summary yet to be filled"),
90         (re.compile(r'.*\b(free software|open source)\b.*', re.IGNORECASE),
91          "No need to specify that the app is Free Software"),
92         (re.compile(r'.*((your|for).*android|android.*(app|device|client|port|version))', re.IGNORECASE),
93          "No need to specify that the app is for Android"),
94         (re.compile(r'.*[a-z0-9][.!?]( |$)'),
95          "Punctuation should be avoided"),
96     ],
97     'Description': [
98         (re.compile(r'^No description available$'),
99          "Description yet to be filled"),
100         (re.compile(r'\s*[*#][^ .]'),
101          "Invalid bulleted list"),
102         (re.compile(r'^\s'),
103          "Unnecessary leading space"),
104         (re.compile(r'.*\s$'),
105          "Unnecessary trailing space"),
106         (re.compile(r'.*([^[]|^)\[[^:[\]]+( |\]|$)'),
107          "Invalid link - use [http://foo.bar Link title] or [http://foo.bar]"),
108         (re.compile(r'.*[^[]https?://[^ ]+'),
109          "Unlinkified link - use [http://foo.bar Link title] or [http://foo.bar]"),
110     ],
111 }
112
113 categories = Set([
114     "Connectivity",
115     "Development",
116     "Games",
117     "Internet",
118     "Multimedia",
119     "Navigation",
120     "Office",
121     "Phone & SMS",
122     "Reading",
123     "Science & Education",
124     "Security",
125     "System",
126     "Theming",
127 ])
128
129 desc_url = re.compile("[^[]\[([^ ]+)( |\]|$)")
130
131
132 def main():
133
134     global config, options, curid, count
135     curid = None
136
137     count = Counter()
138
139     def warn(message):
140         global curid, count
141         if curid:
142             print "%s:" % curid
143             curid = None
144             count['app'] += 1
145         print '    %s' % message
146         count['warn'] += 1
147
148     # Parse command line...
149     parser = OptionParser(usage="Usage: %prog [options] [APPID [APPID ...]]")
150     parser.add_option("-v", "--verbose", action="store_true", default=False,
151                       help="Spew out even more information than normal")
152     parser.add_option("-q", "--quiet", action="store_true", default=False,
153                       help="Restrict output to warnings and errors")
154     (options, args) = parser.parse_args()
155
156     config = common.read_config(options)
157
158     # Get all apps...
159     allapps = metadata.read_metadata(xref=True)
160     apps = common.read_app_args(args, allapps, False)
161
162     filling_ucms = re.compile('^(Tags.*|RepoManifest.*)')
163
164     for appid, app in apps.iteritems():
165         if app['Disabled']:
166             continue
167
168         curid = appid
169         count['app_total'] += 1
170
171         # enabled_builds = 0
172         lowest_vercode = -1
173         curbuild = None
174         for build in app['builds']:
175             if not build['disable']:
176                 # enabled_builds += 1
177                 vercode = int(build['vercode'])
178                 if lowest_vercode == -1 or vercode < lowest_vercode:
179                     lowest_vercode = vercode
180             if not curbuild or int(build['vercode']) > int(curbuild['vercode']):
181                 curbuild = build
182
183         # Incorrect UCM
184         if (curbuild and curbuild['commit']
185                 and app['Update Check Mode'] == 'RepoManifest'
186                 and not curbuild['commit'].startswith('unknown')
187                 and curbuild['vercode'] == app['Current Version Code']
188                 and not curbuild['forcevercode']
189                 and any(s in curbuild['commit'] for s in '.,_-/')):
190             warn("Last used commit '%s' looks like a tag, but Update Check Mode is '%s'" % (
191                 curbuild['commit'], app['Update Check Mode']))
192
193         # Summary size limit
194         summ_chars = len(app['Summary'])
195         if summ_chars > config['char_limits']['Summary']:
196             warn("Summary of length %s is over the %i char limit" % (
197                 summ_chars, config['char_limits']['Summary']))
198
199         # Redundant info
200         if app['Web Site'] and app['Source Code']:
201             if app['Web Site'].lower() == app['Source Code'].lower():
202                 warn("Website '%s' is just the app's source code link" % app['Web Site'])
203
204         if filling_ucms.match(app['Update Check Mode']):
205             if all(app[f] == metadata.app_defaults[f] for f in [
206                     'Auto Name',
207                     'Current Version',
208                     'Current Version Code',
209                     ]):
210                 warn("UCM is set but it looks like checkupdates hasn't been run yet")
211
212         if app['Update Check Name'] == appid:
213             warn("Update Check Name is set to the known app id - it can be removed")
214
215         cvc = int(app['Current Version Code'])
216         if cvc > 0 and cvc < lowest_vercode:
217             warn("Current Version Code is lower than any enabled build")
218
219         # Missing or incorrect categories
220         if not app['Categories']:
221             warn("Categories are not set")
222         for categ in app['Categories']:
223             if categ not in categories:
224                 warn("Category '%s' is not valid" % categ)
225
226         if app['Name'] and app['Name'] == app['Auto Name']:
227             warn("Name '%s' is just the auto name" % app['Name'])
228
229         name = app['Name'] or app['Auto Name']
230         if app['Summary'] and name:
231             if app['Summary'].lower() == name.lower():
232                 warn("Summary '%s' is just the app's name" % app['Summary'])
233
234         desc = app['Description']
235         if app['Summary'] and desc and len(desc) == 1:
236             if app['Summary'].lower() == desc[0].lower():
237                 warn("Description '%s' is just the app's summary" % app['Summary'])
238
239         # Description size limit
240         desc_charcount = sum(len(l) for l in desc)
241         if desc_charcount > config['char_limits']['Description']:
242             warn("Description of length %s is over the %i char limit" % (
243                 desc_charcount, config['char_limits']['Description']))
244
245         maxcols = 140
246         for l in app['Description']:
247             if any(l.startswith(c) for c in ['*', '#']):
248                 continue
249             if any(len(w) > maxcols for w in l.split(' ')):
250                 continue
251             if len(l) > maxcols:
252                 warn("Description should be wrapped to 80-120 chars")
253                 break
254
255         if (not desc[0] or not desc[-1]
256                 or any(not desc[l - 1] and not desc[l] for l in range(1, len(desc)))):
257             warn("Description has an extra empty line")
258
259         # Check for lists using the wrong characters
260         validchars = ['*', '#']
261         lchar = ''
262         lcount = 0
263         for l in app['Description']:
264             if len(l) < 1:
265                 continue
266
267             for um in desc_url.finditer(l):
268                 url = um.group(1)
269                 for m, r in http_warnings:
270                     if m.match(url):
271                         warn("URL '%s' in Description: %s" % (url, r))
272
273             c = l.decode('utf-8')[0]
274             if c == lchar:
275                 lcount += 1
276                 if lcount > 3 and lchar not in validchars:
277                     warn("Description has a list (%s) but it isn't bulleted (*) nor numbered (#)" % lchar)
278                     break
279             else:
280                 lchar = c
281                 lcount = 1
282
283         # Regex checks in all kinds of fields
284         for f in regex_warnings:
285             for m, r in regex_warnings[f]:
286                 v = app[f]
287                 if type(v) == str:
288                     if v is None:
289                         continue
290                     if m.match(v):
291                         warn("%s '%s': %s" % (f, v, r))
292                 elif type(v) == list:
293                     for l in v:
294                         if m.match(l):
295                             warn("%s at line '%s': %s" % (f, l, r))
296
297         # Build warnings
298         for build in app['builds']:
299             if build['disable']:
300                 continue
301             for s in ['master', 'origin', 'HEAD', 'default', 'trunk']:
302                 if build['commit'] and build['commit'].startswith(s):
303                     warn("Branch '%s' used as commit in build '%s'" % (
304                         s, build['version']))
305                 for srclib in build['srclibs']:
306                     ref = srclib.split('@')[1].split('/')[0]
307                     if ref.startswith(s):
308                         warn("Branch '%s' used as commit in srclib '%s'" % (
309                             s, srclib))
310
311         if not curid:
312             print
313
314     if count['warn'] > 0:
315         logging.warn("Found a total of %i warnings in %i apps out of %i total." % (
316             count['warn'], count['app'], count['app_total']))
317         sys.exit(1)
318
319
320 if __name__ == "__main__":
321     main()