chiark / gitweb /
Also enforce https on urls without path
[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     filling_ucms = re.compile('^(Tags.*|RepoManifest.*)')
142
143     for appid, app in apps.iteritems():
144         if app['Disabled']:
145             continue
146
147         curid = appid
148         count['app_total'] += 1
149
150         # enabled_builds = 0
151         lowest_vercode = -1
152         curbuild = None
153         for build in app['builds']:
154             if not build['disable']:
155                 # enabled_builds += 1
156                 vercode = int(build['vercode'])
157                 if lowest_vercode == -1 or vercode < lowest_vercode:
158                     lowest_vercode = vercode
159             if not curbuild or int(build['vercode']) > int(curbuild['vercode']):
160                 curbuild = build
161
162         # Incorrect UCM
163         if (curbuild and curbuild['commit']
164                 and app['Update Check Mode'] == 'RepoManifest'
165                 and not curbuild['commit'].startswith('unknown')
166                 and curbuild['vercode'] == app['Current Version Code']
167                 and not curbuild['forcevercode']
168                 and any(s in curbuild['commit'] for s in '.,_-/')):
169             warn("Last used commit '%s' looks like a tag, but Update Check Mode is '%s'" % (
170                 curbuild['commit'], app['Update Check Mode']))
171
172         # Summary size limit
173         summ_chars = len(app['Summary'])
174         if summ_chars > config['char_limits']['Summary']:
175             warn("Summary of length %s is over the %i char limit" % (
176                 summ_chars, config['char_limits']['Summary']))
177
178         # Redundant info
179         if app['Web Site'] and app['Source Code']:
180             if app['Web Site'].lower() == app['Source Code'].lower():
181                 warn("Website '%s' is just the app's source code link" % app['Web Site'])
182
183         if filling_ucms.match(app['Update Check Mode']):
184             if all(app[f] == metadata.app_defaults[f] for f in [
185                     'Auto Name',
186                     'Current Version',
187                     'Current Version Code',
188                     ]):
189                 warn("UCM is set but it looks like checkupdates hasn't been run yet")
190
191         cvc = int(app['Current Version Code'])
192         if cvc > 0 and cvc < lowest_vercode:
193             warn("Current Version Code is lower than any enabled build")
194
195         # Missing or incorrect categories
196         if not app['Categories']:
197             warn("Categories are not set")
198         for categ in app['Categories']:
199             if categ not in categories:
200                 warn("Category '%s' is not valid" % categ)
201
202         if app['Name'] and app['Name'] == app['Auto Name']:
203             warn("Name '%s' is just the auto name" % app['Name'])
204
205         name = app['Name'] or app['Auto Name']
206         if app['Summary'] and name:
207             if app['Summary'].lower() == name.lower():
208                 warn("Summary '%s' is just the app's name" % app['Summary'])
209
210         desc = app['Description']
211         if app['Summary'] and desc and len(desc) == 1:
212             if app['Summary'].lower() == desc[0].lower():
213                 warn("Description '%s' is just the app's summary" % app['Summary'])
214
215         # Description size limit
216         desc_charcount = sum(len(l) for l in desc)
217         if desc_charcount > config['char_limits']['Description']:
218             warn("Description of length %s is over the %i char limit" % (
219                 desc_charcount, config['char_limits']['Description']))
220
221         if (not desc[0] or not desc[-1]
222                 or any(not desc[l - 1] and not desc[l] for l in range(1, len(desc)))):
223             warn("Description has an extra empty line")
224
225         # Check for lists using the wrong characters
226         validchars = ['*', '#']
227         lchar = ''
228         lcount = 0
229         for l in app['Description']:
230             if len(l) < 1:
231                 continue
232             c = l.decode('utf-8')[0]
233             if c == lchar:
234                 lcount += 1
235                 if lcount > 3 and lchar not in validchars:
236                     warn("Description has a list (%s) but it isn't bulleted (*) nor numbered (#)" % lchar)
237                     break
238             else:
239                 lchar = c
240                 lcount = 1
241
242         # Regex checks in all kinds of fields
243         for f in regex_warnings:
244             for m, r in regex_warnings[f]:
245                 t = metadata.metafieldtype(f)
246                 if t == 'string':
247                     if m.match(app[f]):
248                         warn("%s '%s': %s" % (f, app[f], r))
249                 elif t == 'multiline':
250                     for l in app[f]:
251                         if m.match(l):
252                             warn("%s at line '%s': %s" % (f, l, r))
253
254         # Build warnings
255         for build in app['builds']:
256             if build['disable']:
257                 continue
258             for s in ['master', 'origin', 'HEAD', 'default', 'trunk']:
259                 if build['commit'] and build['commit'].startswith(s):
260                     warn("Branch '%s' used as commit in build '%s'" % (
261                         s, build['version']))
262                 for srclib in build['srclibs']:
263                     ref = srclib.split('@')[1].split('/')[0]
264                     if ref.startswith(s):
265                         warn("Branch '%s' used as commit in srclib '%s'" % (
266                             s, srclib))
267
268         if not curid:
269             print
270
271     logging.info("Found a total of %i warnings in %i apps out of %i total." % (
272         count['warn'], count['app'], count['app_total']))
273
274     sys.exit(1 if count['warn'] > 0 else 0)
275
276 if __name__ == "__main__":
277     main()