chiark / gitweb /
123ed688c71759bef6249911333661eecf49269c
[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     "Graphics",
118     "Internet",
119     "Money",
120     "Multimedia",
121     "Navigation",
122     "Phone & SMS",
123     "Reading",
124     "Science & Education",
125     "Security",
126     "Sports & Health",
127     "System",
128     "Theming",
129     "Time",
130     "Writing",
131 ])
132
133 desc_url = re.compile("[^[]\[([^ ]+)( |\]|$)")
134
135
136 def main():
137
138     global config, options, curid, count
139     curid = None
140
141     count = Counter()
142
143     def warn(message):
144         global curid, count
145         if curid:
146             print "%s:" % curid
147             curid = None
148             count['app'] += 1
149         print '    %s' % message
150         count['warn'] += 1
151
152     # Parse command line...
153     parser = OptionParser(usage="Usage: %prog [options] [APPID [APPID ...]]")
154     parser.add_option("-v", "--verbose", action="store_true", default=False,
155                       help="Spew out even more information than normal")
156     parser.add_option("-q", "--quiet", action="store_true", default=False,
157                       help="Restrict output to warnings and errors")
158     (options, args) = parser.parse_args()
159
160     config = common.read_config(options)
161
162     # Get all apps...
163     allapps = metadata.read_metadata(xref=True)
164     apps = common.read_app_args(args, allapps, False)
165
166     filling_ucms = re.compile('^(Tags.*|RepoManifest.*)')
167
168     for appid, app in apps.iteritems():
169         if app['Disabled']:
170             continue
171
172         curid = appid
173         count['app_total'] += 1
174
175         # enabled_builds = 0
176         lowest_vercode = -1
177         curbuild = None
178         for build in app['builds']:
179             if not build['disable']:
180                 # enabled_builds += 1
181                 vercode = int(build['vercode'])
182                 if lowest_vercode == -1 or vercode < lowest_vercode:
183                     lowest_vercode = vercode
184             if not curbuild or int(build['vercode']) > int(curbuild['vercode']):
185                 curbuild = build
186
187         # Incorrect UCM
188         if (curbuild and curbuild['commit']
189                 and app['Update Check Mode'] == 'RepoManifest'
190                 and not curbuild['commit'].startswith('unknown')
191                 and curbuild['vercode'] == app['Current Version Code']
192                 and not curbuild['forcevercode']
193                 and any(s in curbuild['commit'] for s in '.,_-/')):
194             warn("Last used commit '%s' looks like a tag, but Update Check Mode is '%s'" % (
195                 curbuild['commit'], app['Update Check Mode']))
196
197         # Summary size limit
198         summ_chars = len(app['Summary'])
199         if summ_chars > config['char_limits']['Summary']:
200             warn("Summary of length %s is over the %i char limit" % (
201                 summ_chars, config['char_limits']['Summary']))
202
203         # Redundant info
204         if app['Web Site'] and app['Source Code']:
205             if app['Web Site'].lower() == app['Source Code'].lower():
206                 warn("Website '%s' is just the app's source code link" % app['Web Site'])
207
208         if filling_ucms.match(app['Update Check Mode']):
209             if all(app[f] == metadata.app_defaults[f] for f in [
210                     'Auto Name',
211                     'Current Version',
212                     'Current Version Code',
213                     ]):
214                 warn("UCM is set but it looks like checkupdates hasn't been run yet")
215
216         if app['Update Check Name'] == appid:
217             warn("Update Check Name is set to the known app id - it can be removed")
218
219         cvc = int(app['Current Version Code'])
220         if cvc > 0 and cvc < lowest_vercode:
221             warn("Current Version Code is lower than any enabled build")
222
223         # Missing or incorrect categories
224         if not app['Categories']:
225             warn("Categories are not set")
226         for categ in app['Categories']:
227             if categ not in categories:
228                 warn("Category '%s' is not valid" % categ)
229
230         if app['Name'] and app['Name'] == app['Auto Name']:
231             warn("Name '%s' is just the auto name" % app['Name'])
232
233         name = app['Name'] or app['Auto Name']
234         if app['Summary'] and name:
235             if app['Summary'].lower() == name.lower():
236                 warn("Summary '%s' is just the app's name" % app['Summary'])
237
238         desc = app['Description']
239         if app['Summary'] and desc and len(desc) == 1:
240             if app['Summary'].lower() == desc[0].lower():
241                 warn("Description '%s' is just the app's summary" % app['Summary'])
242
243         # Description size limit
244         desc_charcount = sum(len(l) for l in desc)
245         if desc_charcount > config['char_limits']['Description']:
246             warn("Description of length %s is over the %i char limit" % (
247                 desc_charcount, config['char_limits']['Description']))
248
249         maxcols = 140
250         for l in app['Description']:
251             if any(l.startswith(c) for c in ['*', '#']):
252                 continue
253             if any(len(w) > maxcols for w in l.split(' ')):
254                 continue
255             if len(l) > maxcols:
256                 warn("Description should be wrapped to 80-120 chars")
257                 break
258
259         if (not desc[0] or not desc[-1]
260                 or any(not desc[l - 1] and not desc[l] for l in range(1, len(desc)))):
261             warn("Description has an extra empty line")
262
263         seenlines = set()
264         for l in app['Description']:
265             if len(l) < 1:
266                 continue
267             if l in seenlines:
268                 warn("Description has a duplicate line")
269             seenlines.add(l)
270
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_warnings:
275                     if m.match(url):
276                         warn("URL '%s' in Description: %s" % (url, r))
277
278         # Check for lists using the wrong characters
279         validchars = ['*', '#']
280         lchar = ''
281         lcount = 0
282         for l in app['Description']:
283             if len(l) < 1:
284                 lcount = 0
285                 continue
286
287             if l[0] == lchar and l[1] == ' ':
288                 lcount += 1
289                 if lcount > 2 and lchar not in validchars:
290                     warn("Description has a list (%s) but it isn't bulleted (*) nor numbered (#)" % lchar)
291                     break
292             else:
293                 lchar = l[0]
294                 lcount = 1
295
296         # Regex checks in all kinds of fields
297         for f in regex_warnings:
298             for m, r in regex_warnings[f]:
299                 v = app[f]
300                 if type(v) == str:
301                     if v is None:
302                         continue
303                     if m.match(v):
304                         warn("%s '%s': %s" % (f, v, r))
305                 elif type(v) == list:
306                     for l in v:
307                         if m.match(l):
308                             warn("%s at line '%s': %s" % (f, l, r))
309
310         # Build warnings
311         for build in app['builds']:
312             if build['disable']:
313                 continue
314             for s in ['master', 'origin', 'HEAD', 'default', 'trunk']:
315                 if build['commit'] and build['commit'].startswith(s):
316                     warn("Branch '%s' used as commit in build '%s'" % (
317                         s, build['version']))
318                 for srclib in build['srclibs']:
319                     ref = srclib.split('@')[1].split('/')[0]
320                     if ref.startswith(s):
321                         warn("Branch '%s' used as commit in srclib '%s'" % (
322                             s, srclib))
323
324         if not curid:
325             print
326
327     if count['warn'] > 0:
328         logging.warn("Found a total of %i warnings in %i apps out of %i total." % (
329             count['warn'], count['app'], count['app_total']))
330         sys.exit(1)
331
332
333 if __name__ == "__main__":
334     main()