chiark / gitweb /
lint: overhaul, cleaner and saner output
[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 argparse import ArgumentParser
21 import re
22 import common
23 import metadata
24 import sys
25 from sets import Set
26
27 config = None
28 options = None
29
30
31 def enforce_https(domain):
32     return (re.compile(r'.*[^sS]://[^/]*' + re.escape(domain) + r'(/.*)?'),
33             domain + " URLs should always use https://")
34
35 https_enforcings = [
36     enforce_https('github.com'),
37     enforce_https('gitlab.com'),
38     enforce_https('bitbucket.org'),
39     enforce_https('apache.org'),
40     enforce_https('google.com'),
41     enforce_https('svn.code.sf.net'),
42 ]
43
44
45 def forbid_shortener(domain):
46     return (re.compile(r'https?://[^/]*' + re.escape(domain) + r'/.*'),
47             "URL shorteners should not be used")
48
49 http_url_shorteners = [
50     forbid_shortener('goo.gl'),
51     forbid_shortener('t.co'),
52     forbid_shortener('ur1.ca'),
53 ]
54
55 http_checks = https_enforcings + http_url_shorteners + [
56     (re.compile(r'.*github\.com/[^/]+/[^/]+\.git'),
57      "Appending .git is not necessary"),
58     (re.compile(r'(.*/blob/master/|.*raw\.github.com/[^/]*/[^/]*/master/)'),
59      "Use /HEAD/ instead of /master/ to point at a file in the default branch"),
60 ]
61
62 regex_checks = {
63     'Web Site': http_checks + [
64     ],
65     'Source Code': http_checks + [
66     ],
67     'Repo': https_enforcings + [
68     ],
69     'Issue Tracker': http_checks + [
70         (re.compile(r'.*github\.com/[^/]+/[^/]+[/]*$'),
71          "/issues is missing"),
72     ],
73     'Donate': http_checks + [
74         (re.compile(r'.*flattr\.com'),
75          "Flattr donation methods belong in the FlattrID flag"),
76     ],
77     'Changelog': http_checks + [
78     ],
79     'License': [
80         (re.compile(r'^(|None|Unknown)$'),
81          "No license specified"),
82     ],
83     'Summary': [
84         (re.compile(r'^$'),
85          "Summary yet to be filled"),
86         (re.compile(r'.*\b(free software|open source)\b.*', re.IGNORECASE),
87          "No need to specify that the app is Free Software"),
88         (re.compile(r'.*((your|for).*android|android.*(app|device|client|port|version))', re.IGNORECASE),
89          "No need to specify that the app is for Android"),
90         (re.compile(r'.*[a-z0-9][.!?]( |$)'),
91          "Punctuation should be avoided"),
92     ],
93     'Description': [
94         (re.compile(r'^No description available$'),
95          "Description yet to be filled"),
96         (re.compile(r'\s*[*#][^ .]'),
97          "Invalid bulleted list"),
98         (re.compile(r'^\s'),
99          "Unnecessary leading space"),
100         (re.compile(r'.*\s$'),
101          "Unnecessary trailing space"),
102         (re.compile(r'.*([^[]|^)\[[^:[\]]+( |\]|$)'),
103          "Invalid link - use [http://foo.bar Link title] or [http://foo.bar]"),
104         (re.compile(r'.*[^[]https?://[^ ]+'),
105          "Unlinkified link - use [http://foo.bar Link title] or [http://foo.bar]"),
106     ],
107 }
108
109
110 def check_regexes(app):
111     for f, checks in regex_checks.iteritems():
112         for m, r in checks:
113             v = app[f]
114             if type(v) == str:
115                 if v is None:
116                     continue
117                 if m.match(v):
118                     yield "%s '%s': %s" % (f, v, r)
119             elif type(v) == list:
120                 for l in v:
121                     if m.match(l):
122                         yield "%s at line '%s': %s" % (f, l, r)
123
124 desc_url = re.compile("[^[]\[([^ ]+)( |\]|$)")
125
126
127 def get_lastbuild(builds):
128     lowest_vercode = -1
129     lastbuild = None
130     for build in builds:
131         if not build['disable']:
132             vercode = int(build['vercode'])
133             if lowest_vercode == -1 or vercode < lowest_vercode:
134                 lowest_vercode = vercode
135         if not lastbuild or int(build['vercode']) > int(lastbuild['vercode']):
136             lastbuild = build
137     return lastbuild
138
139
140 def check_ucm_tags(app):
141     lastbuild = get_lastbuild(app['builds'])
142     if (lastbuild is not None
143             and lastbuild['commit']
144             and app['Update Check Mode'] == 'RepoManifest'
145             and not lastbuild['commit'].startswith('unknown')
146             and lastbuild['vercode'] == app['Current Version Code']
147             and not lastbuild['forcevercode']
148             and any(s in lastbuild['commit'] for s in '.,_-/')):
149         yield "Last used commit '%s' looks like a tag, but Update Check Mode is '%s'" % (
150             lastbuild['commit'], app['Update Check Mode'])
151
152
153 def check_char_limits(app):
154     limits = config['char_limits']
155
156     summ_chars = len(app['Summary'])
157     if summ_chars > limits['Summary']:
158         yield "Summary of length %s is over the %i char limit" % (
159             summ_chars, limits['Summary'])
160
161     desc_charcount = sum(len(l) for l in app['Description'])
162     if desc_charcount > limits['Description']:
163         yield "Description of length %s is over the %i char limit" % (
164             desc_charcount, limits['Description'])
165
166
167 def check_old_links(app):
168     usual_sites = [
169         'github.com',
170         'gitlab.com',
171         'bitbucket.org',
172     ]
173     old_sites = [
174         'gitorious.org',
175         'code.google.com',
176     ]
177     if any(s in app['Repo'] for s in usual_sites):
178         for f in ['Web Site', 'Source Code', 'Issue Tracker', 'Changelog']:
179             if any(s in app[f] for s in old_sites):
180                 yield "App is in '%s' but has a link to '%s'" % (app['Repo'], app[f])
181
182
183 def check_useless_fields(app):
184     if app['Update Check Name'] == app['id']:
185         yield "Update Check Name is set to the known app id - it can be removed"
186
187 filling_ucms = re.compile('^(Tags.*|RepoManifest.*)')
188
189
190 def check_checkupdates_ran(app):
191     if filling_ucms.match(app['Update Check Mode']):
192         if all(app[f] == metadata.app_defaults[f] for f in [
193                 'Auto Name',
194                 'Current Version',
195                 'Current Version Code',
196                 ]):
197             yield "UCM is set but it looks like checkupdates hasn't been run yet"
198
199
200 def check_empty_fields(app):
201     if not app['Categories']:
202         yield "Categories are not set"
203
204 all_categories = Set([
205     "Connectivity",
206     "Development",
207     "Games",
208     "Graphics",
209     "Internet",
210     "Money",
211     "Multimedia",
212     "Navigation",
213     "Phone & SMS",
214     "Reading",
215     "Science & Education",
216     "Security",
217     "Sports & Health",
218     "System",
219     "Theming",
220     "Time",
221     "Writing",
222 ])
223
224
225 def check_categories(app):
226     for categ in app['Categories']:
227         if categ not in all_categories:
228             yield "Category '%s' is not valid" % categ
229
230
231 def check_duplicates(app):
232     if app['Web Site'] and app['Source Code']:
233         if app['Web Site'].lower() == app['Source Code'].lower():
234             yield "Website '%s' is just the app's source code link" % app['Web Site']
235
236     if app['Name'] and app['Name'] == app['Auto Name']:
237         yield "Name '%s' is just the auto name" % app['Name']
238
239     name = app['Name'] or app['Auto Name']
240     if app['Summary'] and name:
241         if app['Summary'].lower() == name.lower():
242             yield "Summary '%s' is just the app's name" % app['Summary']
243
244     desc = app['Description']
245     if app['Summary'] and desc and len(desc) == 1:
246         if app['Summary'].lower() == desc[0].lower():
247             yield "Description '%s' is just the app's summary" % app['Summary']
248
249     seenlines = set()
250     for l in app['Description']:
251         if len(l) < 1:
252             continue
253         if l in seenlines:
254             yield "Description has a duplicate line"
255         seenlines.add(l)
256
257
258 def check_text_wrap(app):
259     maxcols = 140
260     for l in app['Description']:
261         if any(l.startswith(c) for c in ['*', '#']):
262             continue
263         if any(len(w) > maxcols for w in l.split(' ')):
264             continue
265         if len(l) > maxcols:
266             yield "Description should be wrapped to 80-120 chars"
267             break
268
269
270 def check_mediawiki_links(app):
271     for l in app['Description']:
272         for um in desc_url.finditer(l):
273             url = um.group(1)
274             for m, r in http_checks:
275                 if m.match(url):
276                     yield "URL '%s' in Description: %s" % (url, r)
277
278
279 def check_extra_spacing(app):
280     desc = app['Description']
281     if (not desc[0] or not desc[-1]
282             or any(not desc[l - 1] and not desc[l] for l in range(1, len(desc)))):
283         yield "Description has an extra empty line"
284
285
286 def check_bulleted_lists(app):
287     validchars = ['*', '#']
288     lchar = ''
289     lcount = 0
290     for l in app['Description']:
291         if len(l) < 1:
292             lcount = 0
293             continue
294
295         if l[0] == lchar and l[1] == ' ':
296             lcount += 1
297             if lcount > 2 and lchar not in validchars:
298                 yield "Description has a list (%s) but it isn't bulleted (*) nor numbered (#)" % lchar
299                 break
300         else:
301             lchar = l[0]
302             lcount = 1
303
304
305 def check_builds(app):
306     for build in app['builds']:
307         if build['disable']:
308             continue
309         for s in ['master', 'origin', 'HEAD', 'default', 'trunk']:
310             if build['commit'] and build['commit'].startswith(s):
311                 yield "Branch '%s' used as commit in build '%s'" % (s, build['version'])
312             for srclib in build['srclibs']:
313                 ref = srclib.split('@')[1].split('/')[0]
314                 if ref.startswith(s):
315                     yield "Branch '%s' used as commit in srclib '%s'" % (s, srclib)
316
317
318 def main():
319
320     global config, options
321
322     anywarns = False
323
324     # Parse command line...
325     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
326     parser.add_argument("appid", nargs='*', help="app-id in the form APPID")
327     parser.add_argument("-v", "--verbose", action="store_true", default=False,
328                         help="Spew out even more information than normal")
329     parser.add_argument("-q", "--quiet", action="store_true", default=False,
330                         help="Restrict output to warnings and errors")
331     options = parser.parse_args()
332
333     config = common.read_config(options)
334
335     # Get all apps...
336     allapps = metadata.read_metadata(xref=True)
337     apps = common.read_app_args(options.appid, allapps, False)
338
339     for appid, app in apps.iteritems():
340         if app['Disabled']:
341             continue
342
343         warns = []
344
345         for check_func in [
346                 check_regexes,
347                 check_ucm_tags,
348                 check_char_limits,
349                 check_old_links,
350                 check_checkupdates_ran,
351                 check_useless_fields,
352                 check_empty_fields,
353                 check_categories,
354                 check_duplicates,
355                 check_text_wrap,
356                 check_mediawiki_links,
357                 check_extra_spacing,
358                 check_bulleted_lists,
359                 check_builds,
360                 ]:
361             warns += check_func(app)
362
363         if warns:
364             anywarns = True
365             for warn in warns:
366                 print "%s: %s" % (appid, warn)
367
368     if anywarns:
369         sys.exit(1)
370
371
372 if __name__ == "__main__":
373     main()