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