chiark / gitweb /
Warn about url shorteners being used
[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
48 def forbid_shortener(domain):
49     return (re.compile(r'https?://[^/]*' + re.escape(domain) + r'/.*'),
50             "URL shorteners should not be used")
51
52 http_url_shorteners = [
53     forbid_shortener('goo.gl'),
54     forbid_shortener('t.co'),
55     forbid_shortener('ur1.ca'),
56 ]
57
58 http_warnings = https_enforcings + http_url_shorteners + [
59     (re.compile(r'.*github\.com/[^/]+/[^/]+\.git'),
60      "Appending .git is not necessary"),
61     # TODO enable in August 2015, when Google Code goes read-only
62     # (re.compile(r'.*://code\.google\.com/.*'),
63     #  "code.google.com will be soon switching down, perhaps the project moved to github.com?"),
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     ],
107 }
108
109 categories = Set([
110     "Children",
111     "Development",
112     "Games",
113     "Internet",
114     "Multimedia",
115     "Navigation",
116     "Office",
117     "Phone & SMS",
118     "Reading",
119     "Science & Education",
120     "Security",
121     "System",
122     "Wallpaper",
123 ])
124
125
126 def main():
127
128     global config, options, curid, count
129     curid = None
130
131     count = Counter()
132
133     def warn(message):
134         global curid, count
135         if curid:
136             print "%s:" % curid
137             curid = None
138             count['app'] += 1
139         print '    %s' % message
140         count['warn'] += 1
141
142     # Parse command line...
143     parser = OptionParser(usage="Usage: %prog [options] [APPID [APPID ...]]")
144     parser.add_option("-v", "--verbose", action="store_true", default=False,
145                       help="Spew out even more information than normal")
146     parser.add_option("-q", "--quiet", action="store_true", default=False,
147                       help="Restrict output to warnings and errors")
148     (options, args) = parser.parse_args()
149
150     config = common.read_config(options)
151
152     # Get all apps...
153     allapps = metadata.read_metadata(xref=False)
154     apps = common.read_app_args(args, allapps, False)
155
156     filling_ucms = re.compile('^(Tags.*|RepoManifest.*)')
157
158     for appid, app in apps.iteritems():
159         if app['Disabled']:
160             continue
161
162         curid = appid
163         count['app_total'] += 1
164
165         # enabled_builds = 0
166         lowest_vercode = -1
167         curbuild = None
168         for build in app['builds']:
169             if not build['disable']:
170                 # enabled_builds += 1
171                 vercode = int(build['vercode'])
172                 if lowest_vercode == -1 or vercode < lowest_vercode:
173                     lowest_vercode = vercode
174             if not curbuild or int(build['vercode']) > int(curbuild['vercode']):
175                 curbuild = build
176
177         # Incorrect UCM
178         if (curbuild and curbuild['commit']
179                 and app['Update Check Mode'] == 'RepoManifest'
180                 and not curbuild['commit'].startswith('unknown')
181                 and curbuild['vercode'] == app['Current Version Code']
182                 and not curbuild['forcevercode']
183                 and any(s in curbuild['commit'] for s in '.,_-/')):
184             warn("Last used commit '%s' looks like a tag, but Update Check Mode is '%s'" % (
185                 curbuild['commit'], app['Update Check Mode']))
186
187         # Summary size limit
188         summ_chars = len(app['Summary'])
189         if summ_chars > config['char_limits']['Summary']:
190             warn("Summary of length %s is over the %i char limit" % (
191                 summ_chars, config['char_limits']['Summary']))
192
193         # Redundant info
194         if app['Web Site'] and app['Source Code']:
195             if app['Web Site'].lower() == app['Source Code'].lower():
196                 warn("Website '%s' is just the app's source code link" % app['Web Site'])
197
198         if filling_ucms.match(app['Update Check Mode']):
199             if all(app[f] == metadata.app_defaults[f] for f in [
200                     'Auto Name',
201                     'Current Version',
202                     'Current Version Code',
203                     ]):
204                 warn("UCM is set but it looks like checkupdates hasn't been run yet")
205
206         if app['Update Check Name'] == appid:
207             warn("Update Check Name is set to the known app id - it can be removed")
208
209         cvc = int(app['Current Version Code'])
210         if cvc > 0 and cvc < lowest_vercode:
211             warn("Current Version Code is lower than any enabled build")
212
213         # Missing or incorrect categories
214         if not app['Categories']:
215             warn("Categories are not set")
216         for categ in app['Categories']:
217             if categ not in categories:
218                 warn("Category '%s' is not valid" % categ)
219
220         if app['Name'] and app['Name'] == app['Auto Name']:
221             warn("Name '%s' is just the auto name" % app['Name'])
222
223         name = app['Name'] or app['Auto Name']
224         if app['Summary'] and name:
225             if app['Summary'].lower() == name.lower():
226                 warn("Summary '%s' is just the app's name" % app['Summary'])
227
228         desc = app['Description']
229         if app['Summary'] and desc and len(desc) == 1:
230             if app['Summary'].lower() == desc[0].lower():
231                 warn("Description '%s' is just the app's summary" % app['Summary'])
232
233         # Description size limit
234         desc_charcount = sum(len(l) for l in desc)
235         if desc_charcount > config['char_limits']['Description']:
236             warn("Description of length %s is over the %i char limit" % (
237                 desc_charcount, config['char_limits']['Description']))
238
239         if (not desc[0] or not desc[-1]
240                 or any(not desc[l - 1] and not desc[l] for l in range(1, len(desc)))):
241             warn("Description has an extra empty line")
242
243         # Check for lists using the wrong characters
244         validchars = ['*', '#']
245         lchar = ''
246         lcount = 0
247         for l in app['Description']:
248             if len(l) < 1:
249                 continue
250             c = l.decode('utf-8')[0]
251             if c == lchar:
252                 lcount += 1
253                 if lcount > 3 and lchar not in validchars:
254                     warn("Description has a list (%s) but it isn't bulleted (*) nor numbered (#)" % lchar)
255                     break
256             else:
257                 lchar = c
258                 lcount = 1
259
260         # Regex checks in all kinds of fields
261         for f in regex_warnings:
262             for m, r in regex_warnings[f]:
263                 t = metadata.metafieldtype(f)
264                 if t == 'string':
265                     if app[f] is None:
266                         continue
267                     if m.match(app[f]):
268                         warn("%s '%s': %s" % (f, app[f], r))
269                 elif t == 'multiline':
270                     for l in app[f]:
271                         if m.match(l):
272                             warn("%s at line '%s': %s" % (f, l, r))
273
274         # Build warnings
275         for build in app['builds']:
276             if build['disable']:
277                 continue
278             for s in ['master', 'origin', 'HEAD', 'default', 'trunk']:
279                 if build['commit'] and build['commit'].startswith(s):
280                     warn("Branch '%s' used as commit in build '%s'" % (
281                         s, build['version']))
282                 for srclib in build['srclibs']:
283                     ref = srclib.split('@')[1].split('/')[0]
284                     if ref.startswith(s):
285                         warn("Branch '%s' used as commit in srclib '%s'" % (
286                             s, srclib))
287
288         if not curid:
289             print
290
291     logging.info("Found a total of %i warnings in %i apps out of %i total." % (
292         count['warn'], count['app'], count['app_total']))
293
294     sys.exit(1 if count['warn'] > 0 else 0)
295
296 if __name__ == "__main__":
297     main()