chiark / gitweb /
lint: support new per-package subdirs for l18n and dev signatures
[fdroidserver.git] / fdroidserver / lint.py
1 #!/usr/bin/env python3
2 #
3 # lint.py - part of the FDroid server tool
4 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
5 #
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See th
14 # GNU Affero General Public License for more details.
15 #
16 # You should have received a copy of the GNU Affero General Public Licen
17 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19 from argparse import ArgumentParser
20 import os
21 import re
22 import sys
23
24 from . import common
25 from . import metadata
26 from . import rewritemeta
27
28 config = None
29 options = None
30
31
32 def enforce_https(domain):
33     return (re.compile(r'.*[^sS]://[^/]*' + re.escape(domain) + r'(/.*)?'),
34             domain + " URLs should always use https://")
35
36
37 https_enforcings = [
38     enforce_https('github.com'),
39     enforce_https('gitlab.com'),
40     enforce_https('bitbucket.org'),
41     enforce_https('apache.org'),
42     enforce_https('google.com'),
43     enforce_https('svn.code.sf.net'),
44 ]
45
46
47 def forbid_shortener(domain):
48     return (re.compile(r'https?://[^/]*' + re.escape(domain) + r'/.*'),
49             "URL shorteners should not be used")
50
51
52 http_url_shorteners = [
53     forbid_shortener('goo.gl'),
54     forbid_shortener('t.co'),
55     forbid_shortener('ur1.ca'),
56     forbid_shortener('is.gd'),
57     forbid_shortener('bit.ly'),
58     forbid_shortener('tiny.cc'),
59     forbid_shortener('tinyurl.com'),
60 ]
61
62 http_checks = https_enforcings + http_url_shorteners + [
63     (re.compile(r'.*github\.com/[^/]+/[^/]+\.git'),
64      "Appending .git is not necessary"),
65     (re.compile(r'.*://[^/]*(github|gitlab|bitbucket|rawgit)[^/]*/([^/]+/){1,3}master'),
66      "Use /HEAD instead of /master to point at a file in the default branch"),
67 ]
68
69 regex_checks = {
70     'WebSite': http_checks,
71     'SourceCode': http_checks,
72     'Repo': https_enforcings,
73     'IssueTracker': http_checks + [
74         (re.compile(r'.*github\.com/[^/]+/[^/]+/*$'),
75          "/issues is missing"),
76         (re.compile(r'.*gitlab\.com/[^/]+/[^/]+/*$'),
77          "/issues is missing"),
78     ],
79     'Donate': http_checks + [
80         (re.compile(r'.*flattr\.com'),
81          "Flattr donation methods belong in the FlattrID flag"),
82     ],
83     'Changelog': http_checks,
84     'Author Name': [
85         (re.compile(r'^\s'),
86          "Unnecessary leading space"),
87         (re.compile(r'.*\s$'),
88          "Unnecessary trailing space"),
89     ],
90     'License': [
91         (re.compile(r'^(|None|Unknown)$'),
92          "No license specified"),
93     ],
94     'Summary': [
95         (re.compile(r'^$'),
96          "Summary yet to be filled"),
97         (re.compile(r'.*\b(free software|open source)\b.*', re.IGNORECASE),
98          "No need to specify that the app is Free Software"),
99         (re.compile(r'.*((your|for).*android|android.*(app|device|client|port|version))', re.IGNORECASE),
100          "No need to specify that the app is for Android"),
101         (re.compile(r'.*[a-z0-9][.!?]( |$)'),
102          "Punctuation should be avoided"),
103         (re.compile(r'^\s'),
104          "Unnecessary leading space"),
105         (re.compile(r'.*\s$'),
106          "Unnecessary trailing space"),
107     ],
108     'Description': [
109         (re.compile(r'^No description available$'),
110          "Description yet to be filled"),
111         (re.compile(r'\s*[*#][^ .]'),
112          "Invalid bulleted list"),
113         (re.compile(r'^\s'),
114          "Unnecessary leading space"),
115         (re.compile(r'.*\s$'),
116          "Unnecessary trailing space"),
117         (re.compile(r'.*([^[]|^)\[[^:[\]]+( |\]|$)'),
118          "Invalid link - use [http://foo.bar Link title] or [http://foo.bar]"),
119         (re.compile(r'(^|.* )https?://[^ ]+'),
120          "Unlinkified link - use [http://foo.bar Link title] or [http://foo.bar]"),
121     ],
122 }
123
124 locale_pattern = re.compile(r'^[a-z]{2,3}(-[A-Z][A-Z])?$')
125
126
127 def check_regexes(app):
128     for f, checks in regex_checks.items():
129         for m, r in checks:
130             v = app.get(f)
131             t = metadata.fieldtype(f)
132             if t == metadata.TYPE_MULTILINE:
133                 for l in v.splitlines():
134                     if m.match(l):
135                         yield "%s at line '%s': %s" % (f, l, r)
136             else:
137                 if v is None:
138                     continue
139                 if m.match(v):
140                     yield "%s '%s': %s" % (f, v, r)
141
142
143 def get_lastbuild(builds):
144     lowest_vercode = -1
145     lastbuild = None
146     for build in builds:
147         if not build.disable:
148             vercode = int(build.versionCode)
149             if lowest_vercode == -1 or vercode < lowest_vercode:
150                 lowest_vercode = vercode
151         if not lastbuild or int(build.versionCode) > int(lastbuild.versionCode):
152             lastbuild = build
153     return lastbuild
154
155
156 def check_ucm_tags(app):
157     lastbuild = get_lastbuild(app.builds)
158     if (lastbuild is not None
159             and lastbuild.commit
160             and app.UpdateCheckMode == 'RepoManifest'
161             and not lastbuild.commit.startswith('unknown')
162             and lastbuild.versionCode == app.CurrentVersionCode
163             and not lastbuild.forcevercode
164             and any(s in lastbuild.commit for s in '.,_-/')):
165         yield "Last used commit '%s' looks like a tag, but Update Check Mode is '%s'" % (
166             lastbuild.commit, app.UpdateCheckMode)
167
168
169 def check_char_limits(app):
170     limits = config['char_limits']
171
172     if len(app.Summary) > limits['summary']:
173         yield "Summary of length %s is over the %i char limit" % (
174             len(app.Summary), limits['summary'])
175
176     if len(app.Description) > limits['description']:
177         yield "Description of length %s is over the %i char limit" % (
178             len(app.Description), limits['description'])
179
180
181 def check_old_links(app):
182     usual_sites = [
183         'github.com',
184         'gitlab.com',
185         'bitbucket.org',
186     ]
187     old_sites = [
188         'gitorious.org',
189         'code.google.com',
190     ]
191     if any(s in app.Repo for s in usual_sites):
192         for f in ['WebSite', 'SourceCode', 'IssueTracker', 'Changelog']:
193             v = app.get(f)
194             if any(s in v for s in old_sites):
195                 yield "App is in '%s' but has a link to '%s'" % (app.Repo, v)
196
197
198 def check_useless_fields(app):
199     if app.UpdateCheckName == app.id:
200         yield "Update Check Name is set to the known app id - it can be removed"
201
202
203 filling_ucms = re.compile(r'^(Tags.*|RepoManifest.*)')
204
205
206 def check_checkupdates_ran(app):
207     if filling_ucms.match(app.UpdateCheckMode):
208         if not app.AutoName and not app.CurrentVersion and app.CurrentVersionCode == '0':
209             yield "UCM is set but it looks like checkupdates hasn't been run yet"
210
211
212 def check_empty_fields(app):
213     if not app.Categories:
214         yield "Categories are not set"
215
216
217 all_categories = set([
218     "Connectivity",
219     "Development",
220     "Games",
221     "Graphics",
222     "Internet",
223     "Money",
224     "Multimedia",
225     "Navigation",
226     "Phone & SMS",
227     "Reading",
228     "Science & Education",
229     "Security",
230     "Sports & Health",
231     "System",
232     "Theming",
233     "Time",
234     "Writing",
235 ])
236
237
238 def check_categories(app):
239     for categ in app.Categories:
240         if categ not in all_categories:
241             yield "Category '%s' is not valid" % categ
242
243
244 def check_duplicates(app):
245     if app.Name and app.Name == app.AutoName:
246         yield "Name '%s' is just the auto name - remove it" % app.Name
247
248     links_seen = set()
249     for f in ['Source Code', 'Web Site', 'Issue Tracker', 'Changelog']:
250         v = app.get(f)
251         if not v:
252             continue
253         v = v.lower()
254         if v in links_seen:
255             yield "Duplicate link in '%s': %s" % (f, v)
256         else:
257             links_seen.add(v)
258
259     name = app.Name or app.AutoName
260     if app.Summary and name:
261         if app.Summary.lower() == name.lower():
262             yield "Summary '%s' is just the app's name" % app.Summary
263
264     if app.Summary and app.Description and len(app.Description) == 1:
265         if app.Summary.lower() == app.Description[0].lower():
266             yield "Description '%s' is just the app's summary" % app.Summary
267
268     seenlines = set()
269     for l in app.Description.splitlines():
270         if len(l) < 1:
271             continue
272         if l in seenlines:
273             yield "Description has a duplicate line"
274         seenlines.add(l)
275
276
277 desc_url = re.compile(r'(^|[^[])\[([^ ]+)( |\]|$)')
278
279
280 def check_mediawiki_links(app):
281     wholedesc = ' '.join(app.Description)
282     for um in desc_url.finditer(wholedesc):
283         url = um.group(1)
284         for m, r in http_checks:
285             if m.match(url):
286                 yield "URL '%s' in Description: %s" % (url, r)
287
288
289 def check_bulleted_lists(app):
290     validchars = ['*', '#']
291     lchar = ''
292     lcount = 0
293     for l in app.Description.splitlines():
294         if len(l) < 1:
295             lcount = 0
296             continue
297
298         if l[0] == lchar and l[1] == ' ':
299             lcount += 1
300             if lcount > 2 and lchar not in validchars:
301                 yield "Description has a list (%s) but it isn't bulleted (*) nor numbered (#)" % lchar
302                 break
303         else:
304             lchar = l[0]
305             lcount = 1
306
307
308 def check_builds(app):
309     for build in app.builds:
310         if build.disable:
311             if build.disable.startswith('Generated by import.py'):
312                 yield "Build generated by `fdroid import` - remove disable line once ready"
313             continue
314         for s in ['master', 'origin', 'HEAD', 'default', 'trunk']:
315             if build.commit and build.commit.startswith(s):
316                 yield "Branch '%s' used as commit in build '%s'" % (s, build.versionName)
317             for srclib in build.srclibs:
318                 ref = srclib.split('@')[1].split('/')[0]
319                 if ref.startswith(s):
320                     yield "Branch '%s' used as commit in srclib '%s'" % (s, srclib)
321
322
323 def check_files_dir(app):
324     dir_path = os.path.join('metadata', app.id)
325     if not os.path.isdir(dir_path):
326         return
327     files = set()
328     for name in os.listdir(dir_path):
329         path = os.path.join(dir_path, name)
330         if not (os.path.isfile(path) or name == 'signatures' or locale_pattern.match(name)):
331             yield "Found non-file at %s" % path
332             continue
333         files.add(name)
334
335     used = {'signatures', }
336     for build in app.builds:
337         for fname in build.patch:
338             if fname not in files:
339                 yield "Unknown file %s in build '%s'" % (fname, build.versionName)
340             else:
341                 used.add(fname)
342
343     for name in files.difference(used):
344         if locale_pattern.match(name):
345             continue
346         yield "Unused file at %s" % os.path.join(dir_path, name)
347
348
349 def check_format(app):
350     if options.format and not rewritemeta.proper_format(app):
351         yield "Run rewritemeta to fix formatting"
352
353
354 def check_extlib_dir(apps):
355     dir_path = os.path.join('build', 'extlib')
356     files = set()
357     for root, dirs, names in os.walk(dir_path):
358         for name in names:
359             files.add(os.path.join(root, name)[len(dir_path) + 1:])
360
361     used = set()
362     for app in apps:
363         for build in app.builds:
364             for path in build.extlibs:
365                 if path not in files:
366                     yield "%s: Unknown extlib %s in build '%s'" % (app.id, path, build.versionName)
367                 else:
368                     used.add(path)
369
370     for path in files.difference(used):
371         if any(path.endswith(s) for s in [
372                 '.gitignore',
373                 'source.txt', 'origin.txt', 'md5.txt',
374                 'LICENSE', 'LICENSE.txt',
375                 'COPYING', 'COPYING.txt',
376                 'NOTICE', 'NOTICE.txt',
377                 ]):
378             continue
379         yield "Unused extlib at %s" % os.path.join(dir_path, path)
380
381
382 def main():
383
384     global config, options
385
386     # Parse command line...
387     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
388     common.setup_global_opts(parser)
389     parser.add_argument("-f", "--format", action="store_true", default=False,
390                         help="Also warn about formatting issues, like rewritemeta -l")
391     parser.add_argument("appid", nargs='*', help="app-id in the form APPID")
392     metadata.add_metadata_arguments(parser)
393     options = parser.parse_args()
394     metadata.warnings_action = options.W
395
396     config = common.read_config(options)
397
398     # Get all apps...
399     allapps = metadata.read_metadata(xref=True)
400     apps = common.read_app_args(options.appid, allapps, False)
401
402     anywarns = False
403
404     apps_check_funcs = []
405     if len(options.appid) == 0:
406         # otherwise it finds tons of unused extlibs
407         apps_check_funcs.append(check_extlib_dir)
408     for check_func in apps_check_funcs:
409         for warn in check_func(apps.values()):
410             anywarns = True
411             print(warn)
412
413     for appid, app in apps.items():
414         if app.Disabled:
415             continue
416
417         app_check_funcs = [
418             check_regexes,
419             check_ucm_tags,
420             check_char_limits,
421             check_old_links,
422             check_checkupdates_ran,
423             check_useless_fields,
424             check_empty_fields,
425             check_categories,
426             check_duplicates,
427             check_mediawiki_links,
428             check_bulleted_lists,
429             check_builds,
430             check_files_dir,
431             check_format,
432         ]
433
434         for check_func in app_check_funcs:
435             for warn in check_func(app):
436                 anywarns = True
437                 print("%s: %s" % (appid, warn))
438
439     if anywarns:
440         sys.exit(1)
441
442
443 if __name__ == "__main__":
444     main()