Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
galaxyproject
GitHub Repository: galaxyproject/training-material
Path: blob/main/bin/lint.rb
1677 views
1
#!/usr/bin/env ruby
2
# frozen_string_literal: true
3
4
require 'yaml'
5
require 'pathname'
6
require 'find'
7
require 'bibtex'
8
require 'json'
9
require 'kramdown'
10
require 'kramdown-parser-gfm'
11
require 'citeproc/ruby'
12
require 'csl/styles'
13
require './_plugins/util'
14
15
GTN_HOME = Pathname.new(__dir__).parent.to_s
16
17
18
module Gtn
19
20
# A custom module to properly format reviewdog json output
21
module ReviewDogEmitter
22
@CODE_URL = 'https://training.galaxyproject.org/training-material/gtn_rdoc/Gtn/Linter.html'
23
24
def self.delete_text(path: '', idx: 0, text: '', message: 'No message', code: 'GTN000', full_line: '', fn: '')
25
error(
26
path: path,
27
idx: idx,
28
match_start: 0,
29
match_end: text.length,
30
replacement: '',
31
message: message,
32
code: code,
33
full_line: full_line,
34
fn: fn,
35
)
36
end
37
38
def self.file_error(path: '', message: 'None', code: 'GTN:000', fn: '')
39
error(
40
path: path,
41
idx: 0,
42
match_start: 0,
43
match_end: 1,
44
replacement: nil,
45
message: message,
46
code: code,
47
full_line: '',
48
fn: fn
49
)
50
end
51
52
def self.warning(path: '', idx: 0, match_start: 0, match_end: 1,
53
replacement: nil, message: 'No message', code: 'GTN000', full_line: '', fn: '')
54
self.message(
55
path: path,
56
idx: idx,
57
match_start: match_start,
58
match_end: match_end,
59
replacement: replacement,
60
message: message,
61
level: 'WARNING',
62
code: code,
63
full_line: full_line,
64
fn: fn,
65
)
66
end
67
68
def self.error(path: '', idx: 0, match_start: 0, match_end: 1, replacement: nil, message: 'No message',
69
code: 'GTN000', full_line: '', fn: '')
70
self.message(
71
path: path,
72
idx: idx,
73
match_start: match_start,
74
match_end: match_end,
75
replacement: replacement,
76
message: message,
77
level: 'ERROR',
78
code: code,
79
full_line: full_line,
80
fn: fn,
81
)
82
end
83
84
def self.message(path: '', idx: 0, match_start: 0, match_end: 1, replacement: nil, message: 'No message',level: 'WARNING', code: 'GTN000', full_line: '', fn: '')
85
end_area = { 'line' => idx + 1, 'column' => match_end }
86
end_area = { 'line' => idx + 2, 'column' => 1 } if match_end == full_line.length
87
88
res = {
89
'message' => message,
90
'location' => {
91
'path' => path,
92
'range' => {
93
'start' => { 'line' => idx + 1, 'column' => match_start + 1 },
94
'end' => end_area
95
}
96
},
97
'severity' => level
98
}
99
if !code.nil?
100
res['code'] = {
101
'value' => code
102
}
103
if !fn.nil?
104
res['code']['url'] = "#{@CODE_URL}#method-c-#{fn}"
105
end
106
end
107
if !replacement.nil?
108
res['suggestions'] = [{
109
'text' => replacement,
110
'range' => {
111
'start' => { 'line' => idx + 1, 'column' => match_start + 1 },
112
'end' => end_area
113
}
114
}]
115
end
116
res
117
end
118
end
119
120
# This is our ONE central linting script that handles EVERYTHING.
121
module Linter
122
@BAD_TOOL_LINK = /{% tool (\[[^\]]*\])\(\s*https?.*tool_id=([^)]*)\)\s*%}/i
123
@BAD_TOOL_LINK2 = %r{{% tool (\[[^\]]*\])\(\s*https://toolshed.g2([^)]*)\)\s*%}}i
124
@MAYBE_OK_TOOL_LINK = /{% tool (\[[^\]]*\])\(([^)]*)\)\s*%}/i
125
126
def self.find_matching_texts(contents, query)
127
contents.map.with_index do |text, idx|
128
[idx, text, text.match(query)]
129
end.select { |_idx, _text, selected| selected }
130
end
131
132
##
133
# GTN:001 - Setting no_toc is discouraged as headers are useful for learners to link to and to jump to. Setting no_toc removes it from the table of contents which is generally inadvisable.
134
#
135
# Remediation: remove {: .no_toc}
136
def self.fix_notoc(contents)
137
find_matching_texts(contents, /{:\s*.no_toc\s*}/)
138
.map do |idx, text, _selected|
139
ReviewDogEmitter.delete_text(
140
path: @path,
141
idx: idx,
142
text: text,
143
message: 'Setting no_toc is discouraged, these headings provide useful places for readers to jump to.',
144
code: 'GTN:001',
145
full_line: text,
146
fn: __method__.to_s,
147
)
148
end
149
end
150
151
##
152
# GTN:002 - YouTube links are discouraged. Please consider using our include for it:
153
#
154
# E.g, instead of
155
#
156
# <iframe ... youtube.../>
157
#
158
# Consider:
159
#
160
# {% include _includes/youtube.html id="e0vj-0imOLw" title="Difference between climate and weather" %}
161
def self.youtube_bad(contents)
162
find_matching_texts(contents, %r{<iframe.*youtu.?be.*</iframe>})
163
.map do |idx, _text, selected|
164
ReviewDogEmitter.warning(
165
path: @path,
166
idx: idx,
167
match_start: selected.begin(0),
168
match_end: selected.end(0) + 1,
169
replacement: '',
170
message: 'Instead of embedding IFrames to YouTube contents, consider adding this video to the ' \
171
'GTN tutorial "recordings" metadata where it will ' \
172
'be more visible for others.',
173
code: 'GTN:002',
174
fn: __method__.to_s,
175
)
176
end
177
end
178
179
##
180
# GTN:003 - We discourage linking to training.galaxyproject.org or
181
# galaxyproject.github.io/training-material as those are "external" links,
182
# which are slower for us to validate. Every build we run tests to be sure
183
# that every link is valid, but we cannot do that for every external site to
184
# avoid putting unnecessary pressure on them.
185
#
186
# Instead of
187
#
188
# [see this other tutorial(https://training.galaxyproject.org/training-material/topics/admin/tutorials/ansible/tutorial.html)
189
#
190
# Consider:
191
#
192
# [see this other tutorial({% link topics/admin/tutorials/ansible/tutorial.md %})
193
def self.link_gtn_tutorial_external(contents)
194
find_matching_texts(
195
contents,
196
%r{\(https?://(training.galaxyproject.org|galaxyproject.github.io)/training-material/([^)]*)\)}
197
)
198
.map do |idx, _text, selected|
199
# puts "#{idx} 0 #{selected[0]} 1 #{selected[1]} 2 #{selected[2]} 3 #{selected[3]}"
200
ReviewDogEmitter.error(
201
path: @path,
202
idx: idx,
203
# We wrap the entire URL (inside the explicit () in a matching group to make it easy to select/replace)
204
match_start: selected.begin(0) + 1,
205
match_end: selected.end(0),
206
replacement: "{% link #{selected[2].gsub('.html', '.md')} %}",
207
message: 'Please use the link function to link to other pages within the GTN. ' \
208
'It helps us ensure that all links are correct',
209
code: 'GTN:003',
210
fn: __method__.to_s,
211
)
212
end
213
end
214
215
216
##
217
# GTN:003 - We discourage linking to training.galaxyproject.org or
218
# galaxyproject.github.io/training-material as those are "external" links,
219
# which are slower for us to validate. Every build we run tests to be sure
220
# that every link is valid, but we cannot do that for every external site to
221
# avoid putting unnecessary pressure on them.
222
#
223
# Instead of
224
#
225
# [see this other tutorial(https://training.galaxyproject.org/training-material/topics/admin/tutorials/ansible/slides.html)
226
#
227
# Consider:
228
#
229
# [see this other tutorial({% link topics/admin/tutorials/ansible/slides.html %})
230
def self.link_gtn_slides_external(contents)
231
find_matching_texts(
232
contents,
233
%r{\((https?://(training.galaxyproject.org|galaxyproject.github.io)/training-material/(.*slides.html))\)}
234
)
235
.map do |idx, _text, selected|
236
ReviewDogEmitter.error(
237
path: @path,
238
idx: idx,
239
match_start: selected.begin(1),
240
match_end: selected.end(1) + 1,
241
replacement: "{% link #{selected[3]} %}",
242
message: 'Please use the link function to link to other pages within the GTN. ' \
243
'It helps us ensure that all links are correct',
244
code: 'GTN:003',
245
fn: __method__.to_s,
246
)
247
end
248
end
249
250
##
251
# GTN:004 - Instead of linking directly to a DOI and citing it yourself, consider obtaining the BibTeX formatted citation and adding it to a tutorial.bib (or slides.bib) file. Then we can generate a full set of references for the citations and give proper credit.
252
#
253
# Companion function to Gtn::Linter.check_pmids
254
def self.check_dois(contents)
255
find_matching_texts(contents, %r{(\[[^\]]*\]\(https?://doi.org/[^)]*\))})
256
.reject { |_idx, _text, selected| selected[0].match(%r{10.5281/zenodo}) } # Ignoring zenodo
257
.map do |idx, _text, selected|
258
ReviewDogEmitter.warning(
259
path: @path,
260
idx: idx,
261
match_start: selected.begin(0),
262
match_end: selected.end(0) + 2,
263
replacement: '{% cite ... %}',
264
message: 'This looks like a DOI which could be better served by using the built-in Citations mechanism. ' \
265
'You can use https://doi2bib.org to convert your DOI into a .bib formatted entry, ' \
266
'and add to your tutorial.md',
267
code: 'GTN:004',
268
fn: __method__.to_s,
269
)
270
end
271
end
272
273
##
274
# GTN:004 - Instead of linking directly to a PMID URL and citing it yourself, consider obtaining the BibTeX formatted citation and adding it to a tutorial.bib (or slides.bib) file. Then we can generate a full set of references for the citations and give proper credit.
275
#
276
# Companion function to Gtn::Linter.check_dois
277
def self.check_pmids(contents)
278
# https://www.ncbi.nlm.nih.gov/pubmed/24678044
279
find_matching_texts(contents,
280
%r{(\[[^\]]*\]\(https?://www.ncbi.nlm.nih.gov/pubmed//[0-9]*\))}).map do |idx, _text, selected|
281
ReviewDogEmitter.warning(
282
path: @path,
283
idx: idx,
284
match_start: selected.begin(0),
285
match_end: selected.end(0) + 2,
286
replacement: '{% cite ... %}',
287
message: 'This looks like a PMID which could be better served by using the built-in Citations mechanism. ' \
288
'You can use https://doi2bib.org to convert your PMID/PMCID into a .bib formatted entry, ' \
289
'and add to your tutorial.md',
290
code: 'GTN:004',
291
fn: __method__.to_s,
292
)
293
end
294
end
295
296
##
297
# GTN:005 - Using link names like 'here' are unhelpful for learners who are progressing through the material with a screenreader. Please use a more descriptive text for your linke
298
#
299
# Instead of
300
#
301
# see the documentation [here](https://example.com)
302
#
303
# Consider
304
#
305
# see [edgeR's documentation](https://example.com)
306
def self.check_bad_link_text(contents)
307
find_matching_texts(contents, /\[\s*(here|link)\s*\]/i)
308
.map do |idx, _text, selected|
309
ReviewDogEmitter.error(
310
path: @path,
311
idx: idx,
312
match_start: selected.begin(0),
313
match_end: selected.end(0) + 1,
314
replacement: '[Something better here]',
315
message: "Please do not use 'here' as your link title, it is " \
316
'[bad for accessibility](https://usability.yale.edu/web-accessibility/articles/links#link-text). ' \
317
'Instead try restructuring your sentence to have useful descriptive text in the link.',
318
code: 'GTN:005',
319
fn: __method__.to_s,
320
)
321
end
322
end
323
324
##
325
# GTN:006 - This is a potentially incorrect Jekyll/Liquid template function/variable access.
326
#
327
# Variables can be placed into your template like so:
328
#
329
# {{ page.title }}
330
#
331
# And functions can be called like so:
332
#
333
# {% if page.title %}
334
#
335
# So please be sure {{ }} and {% %} are matching.
336
def self.incorrect_calls(contents)
337
a = find_matching_texts(contents, /([^{]|^)(%\s*[^%]*%})/i)
338
.map do |idx, _text, selected|
339
ReviewDogEmitter.error(
340
path: @path,
341
idx: idx,
342
match_start: selected.begin(2),
343
match_end: selected.end(2) + 1,
344
replacement: "{#{selected[2]}",
345
message: 'It looks like you might be missing the opening { of a jekyll function',
346
code: 'GTN:006',
347
fn: __method__.to_s,
348
)
349
end
350
b = find_matching_texts(contents, /{([^%]\s*[^%]* %})/i)
351
.map do |idx, _text, selected|
352
ReviewDogEmitter.error(
353
path: @path,
354
idx: idx,
355
match_start: selected.begin(1),
356
match_end: selected.end(1) + 1,
357
replacement: "%#{selected[1]}",
358
message: 'It looks like you might be missing the opening % of a jekyll function',
359
code: 'GTN:006',
360
fn: __method__.to_s,
361
)
362
end
363
364
c = find_matching_texts(contents, /({%\s*[^%]*%)([^}]|$)/i)
365
.map do |idx, _text, selected|
366
ReviewDogEmitter.error(
367
path: @path,
368
idx: idx,
369
match_start: selected.begin(1),
370
match_end: selected.end(1) + 2,
371
replacement: "#{selected[1]}}#{selected[2]}",
372
message: 'It looks like you might be missing the closing } of a jekyll function',
373
code: 'GTN:006',
374
fn: __method__.to_s,
375
)
376
end
377
378
d = find_matching_texts(contents, /({%\s*[^}]*[^%])}/i)
379
.map do |idx, _text, selected|
380
ReviewDogEmitter.error(
381
path: @path,
382
idx: idx,
383
match_start: selected.begin(1),
384
match_end: selected.end(1) + 1,
385
replacement: "#{selected[1]}%",
386
message: 'It looks like you might be missing the closing % of a jekyll function',
387
code: 'GTN:006',
388
fn: __method__.to_s,
389
)
390
end
391
a + b + c + d
392
end
393
394
@CITATION_LIBRARY = nil
395
396
def self.citation_library
397
if @CITATION_LIBRARY.nil?
398
lib = BibTeX::Bibliography.new
399
(enumerate_type(/bib$/) + enumerate_type(/bib$/, root_dir: 'faqs')).each do |path|
400
b = BibTeX.open(path)
401
b.each do |x|
402
# Record the bib path.
403
x._path = path
404
lib << x
405
end
406
end
407
@CITATION_LIBRARY = lib
408
end
409
410
@CITATION_LIBRARY
411
end
412
413
@JEKYLL_CONFIG = nil
414
415
def self.jekyll_config
416
if @JEKYLL_CONFIG.nil?
417
# Load
418
@JEKYLL_CONFIG = YAML.load_file('_config.yml')
419
end
420
@JEKYLL_CONFIG
421
end
422
423
##
424
# GTN:007 - We could not find a citation key, please be sure it is used in a bibliography somewhere.
425
def self.check_bad_cite(contents)
426
find_matching_texts(contents, /{%\s*cite\s+([^%]*)\s*%}/i)
427
.map do |idx, _text, selected|
428
citation_key = selected[1].strip
429
if citation_library[citation_key].nil?
430
ReviewDogEmitter.error(
431
path: @path,
432
idx: idx,
433
match_start: selected.begin(0),
434
match_end: selected.end(0),
435
replacement: nil,
436
message: "The citation (#{citation_key}) could not be found.",
437
code: 'GTN:007',
438
fn: __method__.to_s,
439
)
440
end
441
end
442
end
443
444
##
445
# GTN:033 - This icon is not known to use. If it is new, please add it to {our configuration.}[https://training.galaxyproject.org/training-material/topics/contributing/tutorials/create-new-tutorial-content/faqs/icons_list.html]
446
def self.check_bad_icon(contents)
447
find_matching_texts(contents, /{%\s*icon\s+([^%]*)\s*%}/i)
448
.map do |idx, _text, selected|
449
icon_key = selected[1].strip.split[0]
450
if jekyll_config['icon-tag'][icon_key].nil?
451
ReviewDogEmitter.error(
452
path: @path,
453
idx: idx,
454
match_start: selected.begin(0),
455
match_end: selected.end(0),
456
replacement: nil,
457
message: "The icon (#{icon_key}) could not be found, please add it to _config.yml.",
458
code: 'GTN:033',
459
fn: __method__.to_s,
460
)
461
end
462
end
463
end
464
465
##
466
# GTN:008 - This snippet is not known to us, please check that it exists somewhere in the snippets/ folder.
467
def self.non_existent_snippet(contents)
468
find_matching_texts(contents, /{%\s*snippet\s+([^ ]*)/i)
469
.reject do |_idx, _text, selected|
470
File.exist?(selected[1])
471
end
472
.map do |idx, _text, selected|
473
ReviewDogEmitter.error(
474
path: @path,
475
idx: idx,
476
match_start: selected.begin(0),
477
match_end: selected.end(0),
478
replacement: nil,
479
message: "This snippet (`#{selected[1]}`) does not seem to exist",
480
code: 'GTN:008',
481
fn: __method__.to_s,
482
)
483
end
484
end
485
486
##
487
# GTN:009 - This looks like an invalid tool link. There are several ways that tool links can be invalid, and only one correct way to reference a tool
488
#
489
# Correct
490
#
491
# {% tool [JBrowse genome browser](toolshed.g2.bx.psu.edu/repos/iuc/jbrowse/jbrowse/1.16.4+galaxy3) %}
492
#
493
# Incorrect
494
#
495
# {% tool [JBrowse genome browser](https://toolshed.g2.bx.psu.edu/repos/iuc/jbrowse/jbrowse/1.16.4+galaxy3) %}
496
# {% tool [JBrowse genome browser](https://toolshed.g2.bx.psu.edu/repos/iuc/jbrowse/jbrowse) %}
497
# {% tool [JBrowse genome browser](jbrowse/1.16.4+galaxy3) %}
498
# {% tool [JBrowse genome browser](https://toolshed.g2.bx.psu.edu/repos/iuc/jbrowse/jbrowse/deadbeefcafe) %}
499
def self.bad_tool_links(contents)
500
find_matching_texts(contents, @BAD_TOOL_LINK) + \
501
find_matching_texts(contents, @BAD_TOOL_LINK2)
502
.map do |idx, _text, selected|
503
ReviewDogEmitter.error(
504
path: @path,
505
idx: idx,
506
match_start: selected.begin(0),
507
match_end: selected.end(0) + 1,
508
replacement: "{% tool #{selected[1]}(#{selected[2]}) %}",
509
message: 'You have used the full tool URL to a specific server, here we only need the tool ID portion.',
510
code: 'GTN:009',
511
fn: __method__.to_s,
512
)
513
end
514
515
find_matching_texts(contents, @MAYBE_OK_TOOL_LINK)
516
.map do |idx, _text, selected|
517
518
if acceptable_tool?(selected[2])
519
next
520
end
521
522
ReviewDogEmitter.error(
523
path: @path,
524
idx: idx,
525
match_start: selected.begin(0),
526
match_end: selected.end(0) + 1,
527
replacement: "{% tool #{selected[1]}(#{selected[2]}) %}",
528
message: 'You have used an invalid tool URL, it should be of the form "toolshed.g2.bx.psu.edu/repos/{owner}/{repo}/{tool}/{version}" (or an internal tool ID) so, please double check.',
529
code: 'GTN:009'
530
)
531
end
532
end
533
534
##
535
# GTN:040 - zenodo.org/api links are invalid in the GTN, please use the zenodo.org/records/id/files/<filename> format instead. This ensures that when users download files from zenodo into Galaxy, they appear correctly, with a useful filename.
536
def self.bad_zenodo_links(contents)
537
find_matching_texts(contents, /https:\/\/zenodo.org\/api\//)
538
.reject { |_idx, _text, selected| _text =~ /files-archive/ }
539
.map do |idx, _text, selected|
540
ReviewDogEmitter.error(
541
path: @path,
542
idx: idx,
543
match_start: selected.begin(0),
544
match_end: selected.end(0) + 1,
545
replacement: nil,
546
message: 'Please do not use zenodo.org/api/ links, instead it should look like zenodo.org/records/id/files/<filename>',
547
code: 'GTN:040',
548
fn: __method__.to_s,
549
)
550
end
551
end
552
553
##
554
# GTN:032 - Snippets are too close together which sometimes breaks snippet rendering. Please ensure snippets are separated by one line.
555
def self.snippets_too_close_together(contents)
556
prev_line = -2
557
res = []
558
find_matching_texts(contents, /^[> ]*{% snippet/)
559
.each do |idx, _text, selected|
560
if idx == prev_line + 1
561
res.push(ReviewDogEmitter.error(
562
path: @path,
563
idx: idx,
564
match_start: selected.begin(0),
565
match_end: selected.end(0) + 1,
566
replacement: nil,
567
message: 'Snippets too close together',
568
code: 'GTN:032',
569
fn: __method__.to_s,
570
))
571
end
572
prev_line = idx
573
end
574
res
575
end
576
577
##
578
# GTN:009 - See Gtn::Linter.bad_tool_links
579
def self.check_tool_link(contents)
580
find_matching_texts(contents, /{%\s*tool \[([^\]]*)\]\(([^)]*)\)\s*%}/)
581
.map do |idx, _text, selected|
582
# text = selected[1]
583
link = selected[2]
584
585
errs = []
586
if link.match(%r{/})
587
if link.count('/') < 5
588
errs.push(ReviewDogEmitter.error(
589
path: @path,
590
idx: idx,
591
match_start: selected.begin(2),
592
match_end: selected.end(2) + 1,
593
replacement: nil,
594
message: "This tool identifier looks incorrect, it doesn't have the right number of segments.",
595
code: 'GTN:009'
596
))
597
end
598
599
if link.match(/testtoolshed/)
600
errs.push(ReviewDogEmitter.warning(
601
path: @path,
602
idx: idx,
603
match_start: selected.begin(2),
604
match_end: selected.end(2) + 1,
605
replacement: nil,
606
message: 'The GTN strongly avoids using testtoolshed tools in your tutorials or workflows',
607
code: 'GTN:009'
608
))
609
end
610
else
611
if link.match(/\+/)
612
errs.push(ReviewDogEmitter.error(
613
path: @path,
614
idx: idx,
615
match_start: selected.begin(2),
616
match_end: selected.end(2) + 1,
617
replacement: nil,
618
message: 'Broken tool link, unnecessary +',
619
code: 'GTN:009'
620
))
621
end
622
623
624
if !acceptable_tool?(link)
625
errs.push(ReviewDogEmitter.error(
626
path: @path,
627
idx: idx,
628
match_start: selected.begin(2),
629
match_end: selected.end(2) + 1,
630
replacement: nil,
631
message: 'Unknown short tool ID. Please use the full tool ID, or check bin/lint.rb ' \
632
'if you believe this is correct.',
633
code: 'GTN:009'
634
))
635
end
636
end
637
638
errs
639
end
640
end
641
642
##
643
# GTN:010 - We have a new, more accessible syntax for box titles. Please use this instead:
644
#
645
# > <box-title>Some Title</box-title>
646
# > ...
647
# {: .box}
648
def self.new_more_accessible_boxes(contents)
649
# \#\#\#
650
find_matching_texts(contents, /> (### {%\s*icon ([^%]*)\s*%}[^:]*:?(.*))/)
651
.map do |idx, _text, selected|
652
key = selected[2].strip.gsub(/_/, '-')
653
ReviewDogEmitter.error(
654
path: @path,
655
idx: idx,
656
match_start: selected.begin(1),
657
match_end: selected.end(1) + 1,
658
replacement: "<#{key}-title>#{selected[3].strip}</#{key}-title>",
659
message: 'We have developed a new syntax for box titles, please consider using this instead.',
660
code: 'GTN:010',
661
fn: __method__.to_s,
662
)
663
end
664
end
665
666
##
667
# GTN:010 - See Gtn::Linter.new_more_accessible_boxes_agenda
668
def self.new_more_accessible_boxes_agenda(contents)
669
# \#\#\#
670
find_matching_texts(contents, /> (###\s+Agenda\s*)/)
671
.map do |idx, _text, selected|
672
ReviewDogEmitter.error(
673
path: @path,
674
idx: idx,
675
match_start: selected.begin(1),
676
match_end: selected.end(1) + 1,
677
replacement: '<agenda-title></agenda-title>',
678
message: 'We have developed a new syntax for box titles, please consider using this instead.',
679
code: 'GTN:010',
680
fn: __method__.to_s,
681
)
682
end
683
end
684
685
##
686
# GTN:011 - Do not use target="_blank" it is bad for accessibility.
687
def self.no_target_blank(contents)
688
find_matching_texts(contents, /target=("_blank"|'_blank')/)
689
.map do |idx, _text, selected|
690
ReviewDogEmitter.warning(
691
path: @path,
692
idx: idx,
693
match_start: selected.begin(0),
694
match_end: selected.end(0),
695
replacement: nil,
696
message: 'Please do not use `target="_blank"`, [it is bad for accessibility.]' \
697
'(https://www.a11yproject.com/checklist/#identify-links-that-open-in-a-new-tab-or-window)',
698
code: 'GTN:011',
699
fn: __method__.to_s,
700
)
701
end
702
end
703
704
##
705
# GTN:034 - Alternative text or alt-text is mandatory for every image in the GTN.
706
def self.empty_alt_text(contents)
707
find_matching_texts(contents, /!\[\]\(/i)
708
.map do |idx, _text, selected|
709
path = selected[1].to_s.strip
710
if !File.exist?(path.gsub(%r{^/}, ''))
711
ReviewDogEmitter.error(
712
path: @path,
713
idx: idx,
714
match_start: selected.begin(0),
715
match_end: selected.end(0),
716
replacement: nil,
717
message: 'The alt text for this image seems to be empty',
718
code: 'GTN:034',
719
fn: __method__.to_s,
720
)
721
end
722
end
723
end
724
725
##
726
# GTN:018 - You have linked to a file but this file could not be found. Check your link to make sure the path exists.
727
#
728
# Note that we use a customised version of Jekyll's link function which will not work correctly for _posts/news items, which should be corrected at some point. We should remove our custom link function and go back to the official one.
729
def self.check_bad_link(contents)
730
find_matching_texts(contents, /{%\s*link\s+([^%]*)\s*%}/i)
731
.map do |idx, _text, selected|
732
path = selected[1].to_s.strip
733
if !File.exist?(path.gsub(%r{^/}, ''))
734
ReviewDogEmitter.error(
735
path: @path,
736
idx: idx,
737
match_start: selected.begin(0),
738
match_end: selected.end(0),
739
replacement: nil,
740
message: "The linked file (`#{selected[1].strip}`) could not be found.",
741
code: 'GTN:018',
742
fn: __method__.to_s,
743
)
744
end
745
end
746
747
find_matching_texts(contents, /\]\(\)/i)
748
.map do |idx, _text, selected|
749
path = selected[1].to_s.strip
750
if !File.exist?(path.gsub(%r{^/}, ''))
751
ReviewDogEmitter.error(
752
path: @path,
753
idx: idx,
754
match_start: selected.begin(0),
755
match_end: selected.end(0),
756
replacement: nil,
757
message: 'The link does not seem to have a target.',
758
code: 'GTN:018',
759
fn: __method__.to_s,
760
)
761
end
762
end
763
end
764
765
##
766
# GTN:036 - You have used the TRS snippet to link to a TRS ID but the link does not seem to be correct.
767
def self.check_bad_trs_link(contents)
768
find_matching_texts(contents, %r{snippet faqs/galaxy/workflows_run_trs.md path="([^"]*)"}i)
769
.map do |idx, _text, selected|
770
path = selected[1].to_s.strip
771
if !File.exist?(path)
772
ReviewDogEmitter.error(
773
path: @path,
774
idx: idx,
775
match_start: selected.begin(0),
776
match_end: selected.end(0),
777
replacement: nil,
778
message: "The linked file (`#{path}`) could not be found.",
779
code: 'GTN:036',
780
fn: __method__.to_s,
781
)
782
end
783
end
784
end
785
786
##
787
# GTN:020 - Please do not bold random lines, use a heading properly.
788
def self.check_looks_like_heading(contents)
789
# TODO: we should remove this someday, but, we need to have a good solution
790
# and we're still a ways from that.
791
#
792
# There's no clear way to say "this subsection of the content has its own hierarchy"
793
return if @path.match(/faq/)
794
795
find_matching_texts(contents, /^\*\*(.*)\*\*$/)
796
.map do |idx, _text, selected|
797
ReviewDogEmitter.warning(
798
path: @path,
799
idx: idx,
800
match_start: selected.begin(1),
801
match_end: selected.end(1) + 1,
802
replacement: "### #{selected[1]}",
803
message: "This looks like a heading, but isn't. Please use proper semantic headings where possible. " \
804
'You should check the heading level of this suggestion, rather than accepting the change as-is.',
805
code: 'GTN:020',
806
fn: __method__.to_s,
807
)
808
end
809
end
810
811
@KNOWN_TAGS = [
812
# GTN
813
'cite',
814
'snippet',
815
'link',
816
'icon',
817
'tool',
818
'color',
819
820
'set', # This isn't strictly GTN, it's seen inside a raw in a tool tutorial.
821
# Jekyll
822
'if', 'else', 'elsif', 'endif',
823
'capture', 'assign', 'include',
824
'comment', 'endcomment',
825
'for', 'endfor',
826
'unless', 'endunless',
827
'raw', 'endraw'
828
].freeze
829
830
##
831
# GTN:021 - We are not sure this tag is correct, there is a very limited set of Jekyll/liquid tags that are used in GTN tutorials, and this checks for surprises.
832
def self.check_bad_tag(contents)
833
find_matching_texts(contents, /{%\s*(?<tag>[a-z]+)/)
834
.reject { |_idx, _text, selected| @KNOWN_TAGS.include? selected[:tag] }
835
.map do |idx, _text, selected|
836
ReviewDogEmitter.warning(
837
path: @path,
838
idx: idx,
839
match_start: selected.begin(1),
840
match_end: selected.end(1) + 1,
841
replacement: nil,
842
message: "We're not sure this tag is correct (#{selected[:tag]}), it isn't one of the known tags.",
843
code: 'GTN:021',
844
fn: __method__.to_s,
845
)
846
end
847
end
848
849
@BOX_CLASSES = %w[
850
agenda
851
code-in
852
code-out
853
comment
854
details
855
feedback
856
hands-on
857
hands_on
858
question
859
solution
860
tip
861
warning
862
].freeze
863
864
##
865
# GTN:022 - Please do not prefix your boxes of a type with the box name.
866
#
867
# Do not do:
868
#
869
# > <question-title>Question: Some question!</question-title>
870
#
871
# Instead:
872
#
873
# > <question-title>Some question!</question-title>
874
#
875
# As the Question: prefix will be added automatically when necessary. This goes also for tip/comment/etc.
876
def self.check_useless_box_prefix(contents)
877
find_matching_texts(contents, /<(?<tag>[a-z_-]+)-title>(?<fw>[a-zA-Z_-]+:?\s*)/)
878
.select do |_idx, _text, selected|
879
@BOX_CLASSES.include?(selected[:tag]) and selected[:tag] == selected[:fw].gsub(/:\s*$/, '').downcase
880
end
881
.map do |idx, _text, selected|
882
ReviewDogEmitter.warning(
883
path: @path,
884
idx: idx,
885
match_start: selected.begin(2),
886
match_end: selected.end(2) + 1,
887
replacement: '',
888
message: "It is no longer necessary to prefix your #{selected[:tag]} box titles with " \
889
"#{selected[:tag].capitalize}, this is done automatically.",
890
code: 'GTN:022',
891
fn: __method__.to_s,
892
)
893
end
894
end
895
896
##
897
# GTN:028 - Your headings are out of order. Please check it properly, that you do not skip levels.
898
def self.check_bad_heading_order(contents)
899
doc = Kramdown::Document.new(contents.join("\n"), input: 'GFM')
900
headers = doc.root.children.select{|k| k.type == :header}
901
902
bad_depth = headers
903
.each_cons(2) # Two items at a time
904
.select{|k1, k2| k2.options[:level] - k1.options[:level] > 1} # All that have a >1 shift in heading depth
905
.map{|_,b | b} # Only the second, failing one.
906
907
all_headings = headers
908
.map{|k| "#" * k.options[:level] + " "+ k.options[:raw_text] }
909
910
bad_depth.map{|k|
911
ReviewDogEmitter.error(
912
path: @path,
913
idx: k.options[:location] - 1,
914
match_start: 0,
915
match_end: k.options[:raw_text].length + k.options[:level] + 1,
916
replacement: '#' * (k.options[:level] - 1),
917
message: "You have skipped a heading level, please correct this.\n<details>" \
918
"<summary>Listing of Heading Levels</summary>\n\n```\n#{all_headings.join("\n")}\n```\n</details>",
919
code: 'GTN:028',
920
fn: __method__.to_s,
921
)
922
}
923
end
924
925
##
926
# GTN:029 - Please do not bold headings
927
def self.check_bolded_heading(contents)
928
find_matching_texts(contents, /^#+ (?<title>\*\*.*\*\*)$/)
929
.map do |idx, _text, selected|
930
ReviewDogEmitter.error(
931
path: @path,
932
idx: idx,
933
match_start: selected.begin(1),
934
match_end: selected.end(1) + 1,
935
replacement: selected[:title][2..-3],
936
message: 'Please do not bold headings, it is unncessary ' \
937
'and will potentially cause screen readers to shout them.',
938
code: 'GTN:029',
939
fn: __method__.to_s,
940
)
941
end
942
end
943
944
##
945
# GTN:032 - zenodo.org/api links are invalid in the GTN, please use the zenodo.org/records/id/files/<filename> format instead. This ensures that when users download files from zenodo into Galaxy, they appear correctly, with a useful filename.
946
#
947
# Seems to be a duplicate of Gtn::Linter.bad_zenodo_links
948
def self.zenodo_api(contents)
949
find_matching_texts(contents, %r{(zenodo\.org/api/files/)})
950
.map do |idx, _text, selected|
951
ReviewDogEmitter.error(
952
path: @path,
953
idx: idx,
954
match_start: selected.begin(1),
955
match_end: selected.end(1) + 1,
956
replacement: nil,
957
message: 'The Zenodo.org/api URLs are not stable, you must use a URL of the format zenodo.org/record/...',
958
code: 'GTN:032'
959
)
960
end
961
end
962
963
##
964
# GTN:035 - This is a non-semantic list which is bad for accessibility and screenreaders.
965
#
966
# Do not do:
967
#
968
# * Step 1. Some text
969
# * Step 2. some other thing
970
#
971
# Do not do:
972
#
973
# Step 1. Some text
974
# Step 2. some other thing
975
#
976
# Instead:
977
#
978
# 1. some text
979
# 2. some other
980
#
981
# That is a proper semantic list.
982
def self.nonsemantic_list(contents)
983
find_matching_texts(contents, />\s*(\*\*\s*[Ss]tep)/)
984
.map do |idx, _text, selected|
985
ReviewDogEmitter.error(
986
path: @path,
987
idx: idx,
988
match_start: selected.begin(1),
989
match_end: selected.end(1) + 1,
990
replacement: nil,
991
message: 'This is a non-semantic list which is bad for accessibility and bad for screenreaders. ' \
992
'It results in poorly structured HTML and as a result is not allowed.',
993
code: 'GTN:035',
994
fn: __method__.to_s,
995
)
996
end
997
end
998
999
##
1000
# GTN:041, GTN:042, GTN:043, GTN:044, GTN:045 - This checks for a myriad variety of CYOA issues. Please see the error message for help resolving them.
1001
def self.cyoa_branches(contents)
1002
joined_contents = contents.join("\n")
1003
cyoa_branches = joined_contents.scan(/_includes\/cyoa-choices[^%]*%}/m)
1004
.map{|cyoa_line|
1005
cyoa_line.gsub(/\n/, ' ') # Remove newlines, want it all one one line.
1006
.gsub(/\s+/, ' ') # Collapse multiple whitespace for simplicity
1007
.gsub(/_includes\/cyoa-choices.html/, '').gsub(/%}$/, '') # Strip start/end
1008
.strip
1009
.split('" ') # Split on the end of an option to get the individual option groups
1010
.map{|p| p.gsub(/="/, '=').split('=')}.to_h} # convert it into a convenient hash
1011
# NOTE: Errors on this line usually mean that folks have used ' instead of " in their CYOA.
1012
1013
1014
# cyoa_branches =
1015
# [{"option1"=>"Quick one tool method",
1016
# "option2"=>"Convert to AnnData object compatible with Filter, Plot, Explore workflow",
1017
# "default"=>"Quick one tool method",
1018
# "text"=>"Choose below if you just want to convert your object quickly or see how it all happens behind the scenes!",
1019
# "disambiguation"=>"seurat2anndata\""},
1020
1021
# We use slugify_unsafe to convert it to a slug, now we should check:
1022
# 1. Is it unique in the file? No duplicate options?
1023
# 2. Is every branch used?
1024
1025
# Uniqueness:
1026
options = cyoa_branches.map{|o| o.select{|k, v| k =~ /option/}.values}.flatten
1027
slugified = options.map{|o| [o, unsafe_slugify(o)]}
1028
slugified_grouped = slugified.group_by{|before, after| after}
1029
.map{|k, pairs| [k, pairs.map{|p| p[0]}]}.to_h
1030
1031
errors = []
1032
if slugified_grouped.values.any?{|v| v.length > 1}
1033
dupes = slugified_grouped.select{|k, v| v.length > 1}
1034
msg = "We identified the following duplicate options in your CYOA: "
1035
msg += dupes.map do |slug, options|
1036
"Options #{options.join(', ')} became the key: #{slug}"
1037
end.join("; ")
1038
1039
errors << ReviewDogEmitter.error(
1040
path: @path,
1041
idx: 0,
1042
match_start: 0,
1043
match_end: 1,
1044
replacement: nil,
1045
message: 'You have non-unique options in your Choose Your Own Adventure. Please ensure that each option is unique in its text. Unfortunately we do not currently support re-using the same option text across differently disambiguated CYOA branches, so, please inform us if this is a requirement for you.' + msg,
1046
code: 'GTN:041',
1047
fn: __method__.to_s,
1048
)
1049
end
1050
1051
# Missing default
1052
cyoa_branches.each do |branch|
1053
if branch['default'].nil?
1054
errors << ReviewDogEmitter.error(
1055
path: @path,
1056
idx: 0,
1057
match_start: 0,
1058
match_end: 1,
1059
replacement: nil,
1060
message: 'We recommend specifying a default for every branch',
1061
code: 'GTN:042',
1062
fn: __method__.to_s,
1063
)
1064
end
1065
1066
# Checking default/options correspondence.
1067
options = branch.select{|k, v| k =~ /option/}.values
1068
if branch.key?("default") && ! options.include?(branch['default'])
1069
if options.any?{|o| unsafe_slugify(o) == unsafe_slugify(branch['default'])}
1070
errors << ReviewDogEmitter.warning(
1071
path: @path,
1072
idx: 0,
1073
match_start: 0,
1074
match_end: 1,
1075
replacement: nil,
1076
message: "We did not see a corresponding option# for the default: «#{branch['default']}», but this could have been written before we automatically slugified the options. If you like, please consider making your default option match the option text exactly.",
1077
code: 'GTN:043',
1078
fn: __method__.to_s,
1079
)
1080
else
1081
errors << ReviewDogEmitter.warning(
1082
path: @path,
1083
idx: 0,
1084
match_start: 0,
1085
match_end: 1,
1086
replacement: nil,
1087
message: "We did not see a corresponding option# for the default: «#{branch['default']}», please ensure the text matches one of the branches.",
1088
code: 'GTN:044',
1089
fn: __method__.to_s,
1090
)
1091
end
1092
end
1093
end
1094
1095
# Branch testing.
1096
cyoa_branches.each do |branch|
1097
options = branch
1098
.select{|k, v| k =~ /option/}
1099
.values
1100
1101
# Check for matching lines in the file.
1102
options.each do |option|
1103
slug_option = unsafe_slugify(option)
1104
if !joined_contents.match(/#{slug_option}/)
1105
errors << ReviewDogEmitter.warning(
1106
path: @path,
1107
idx: 0,
1108
match_start: 0,
1109
match_end: 1,
1110
replacement: nil,
1111
message: "We did not see a branch for #{option} (#{slug_option}) in the file. Please consider ensuring that all options are used.",
1112
code: 'GTN:045',
1113
fn: __method__.to_s,
1114
)
1115
end
1116
end
1117
end
1118
1119
# find_matching_texts(contents, />\s*(\*\*\s*[Ss]tep)/) .map do |idx, _text, selected|
1120
# ReviewDogEmitter.error(
1121
# path: @path,
1122
# idx: idx,
1123
# match_start: selected.begin(1),
1124
# match_end: selected.end(1) + 1,
1125
# replacement: nil,
1126
# message: 'This is a non-semantic list which is bad for accessibility and bad for screenreaders. ' \
1127
# 'It results in poorly structured HTML and as a result is not allowed.',
1128
# code: 'GTN:035'
1129
# )
1130
# end
1131
errors
1132
end
1133
1134
##
1135
# GTN:046 - Please do not add an # Introduction section, as it is unnecessary, please start directly into an abstract or hook for your tutorial that will get the learner interested in the material.
1136
def self.useless_intro(contents)
1137
joined_contents = contents.join("\n")
1138
joined_contents.scan(/\n---\n+# Introduction/m)
1139
.map do |line|
1140
ReviewDogEmitter.error(
1141
path: @path,
1142
idx: 0,
1143
match_start: 0,
1144
match_end: 0,
1145
replacement: '',
1146
message: 'Please do not include an # Introduction section, it is unnecessary here, just start directly into your text. The first paragraph that is seen by our infrastructure will automatically be shown in a few places as an abstract.',
1147
code: 'GTN:046',
1148
fn: __method__.to_s,
1149
)
1150
end
1151
end
1152
1153
def self.fix_md(contents)
1154
[
1155
*fix_notoc(contents),
1156
*youtube_bad(contents),
1157
*link_gtn_slides_external(contents),
1158
*link_gtn_tutorial_external(contents),
1159
*check_dois(contents),
1160
*check_pmids(contents),
1161
*check_bad_link_text(contents),
1162
*incorrect_calls(contents),
1163
*check_bad_cite(contents),
1164
*non_existent_snippet(contents),
1165
*bad_tool_links(contents),
1166
*check_tool_link(contents),
1167
*new_more_accessible_boxes(contents),
1168
*new_more_accessible_boxes_agenda(contents),
1169
*no_target_blank(contents),
1170
*check_bad_link(contents),
1171
*check_bad_icon(contents),
1172
*check_looks_like_heading(contents),
1173
*check_bad_tag(contents),
1174
*check_useless_box_prefix(contents),
1175
*check_bad_heading_order(contents),
1176
*check_bolded_heading(contents),
1177
*snippets_too_close_together(contents),
1178
*bad_zenodo_links(contents),
1179
*zenodo_api(contents),
1180
*empty_alt_text(contents),
1181
*check_bad_trs_link(contents),
1182
*nonsemantic_list(contents),
1183
*cyoa_branches(contents),
1184
*useless_intro(contents)
1185
]
1186
end
1187
1188
def self.bib_missing_mandatory_fields(bib)
1189
results = []
1190
bib.each do |x|
1191
begin
1192
doi = x.doi
1193
rescue StandardError
1194
doi = nil
1195
end
1196
1197
begin
1198
url = x.url
1199
rescue StandardError
1200
url = nil
1201
end
1202
1203
begin
1204
isbn = x.isbn
1205
rescue StandardError
1206
isbn = nil
1207
end
1208
1209
results.push([x.key, 'Missing a DOI, URL or ISBN. Please add one of the three.']) if doi.nil? && url.nil? && isbn.nil?
1210
1211
begin
1212
x.title
1213
results.push([x.key, 'This entry is missing a title attribute. Please add it.']) if !x.title
1214
rescue StandardError
1215
results.push([x.key, 'This entry is missing a title attribute. Please add it.'])
1216
end
1217
end
1218
results
1219
end
1220
1221
##
1222
# GTN:015, GTN:016, GTN:025, GTN:026, GTN:027 others.
1223
# These error messages indicate something is amiss with your workflow. Please consult the error message to correct it.
1224
def self.fix_ga_wf(contents)
1225
results = []
1226
if !contents.key?('tags') or contents['tags'].empty?
1227
path_parts = @path.split('/')
1228
topic = path_parts[path_parts.index('topics') + 1]
1229
1230
results.push(ReviewDogEmitter.file_error(
1231
path: @path, message: "This workflow is missing required tags. Please add `\"tags\": [\"#{topic}\"]`",
1232
code: 'GTN:015',
1233
fn: __method__.to_s,
1234
))
1235
end
1236
1237
if !contents.key?('annotation')
1238
results.push(ReviewDogEmitter.file_error(
1239
path: @path,
1240
message: 'This workflow is missing an annotation. Please add `"annotation": "title of tutorial"`',
1241
code: 'GTN:016',
1242
fn: __method__.to_s,
1243
))
1244
end
1245
1246
if !contents.key?('license')
1247
results.push(ReviewDogEmitter.file_error(
1248
path: @path,
1249
message: 'This workflow is missing a license. Please select a valid OSI license. ' \
1250
'You can correct this in the Galaxy workflow editor.',
1251
code: 'GTN:026',
1252
fn: __method__.to_s,
1253
))
1254
end
1255
1256
tool_ids = tool_id_extractor(contents)
1257
1258
# Check if they use TS tools, we do this here because it's easier to look at the plain text.
1259
tool_ids.each do |step_id, id|
1260
if ! acceptable_tool?(id)
1261
results += [
1262
ReviewDogEmitter.error(
1263
path: @path,
1264
idx: 0,
1265
match_start: 0,
1266
match_end: 0,
1267
replacement: nil,
1268
message: "A step in your workflow (#{step_id}) uses an invalid tool ID (#{id}) or a tool ID from the testtoolshed. These are not permitted in GTN tutorials. If this is in error, you can add it to the top of _plugins/utils.rb",
1269
code: 'GTN:017',
1270
fn: __method__.to_s,
1271
)
1272
]
1273
end
1274
end
1275
1276
1277
1278
1279
if contents.key?('creator')
1280
contents['creator']
1281
.select { |c| c['class'] == 'Person' }
1282
.each do |p|
1283
if !p.key?('identifier') || (p['identifier'] == '')
1284
results.push(ReviewDogEmitter.file_error(
1285
path: @path,
1286
message: 'This workflow has a creator but is missing an identifier for them. ' \
1287
'Please ensure all creators have valid ORCIDs.',
1288
code: 'GTN:025'
1289
))
1290
end
1291
1292
if !p.key?('name') || (p['name'] == '')
1293
results.push(ReviewDogEmitter.file_error(
1294
path: @path, message: 'This workflow has a creator but is a name, please add it.',
1295
code: 'GTN:025'
1296
))
1297
end
1298
end
1299
else
1300
results.push(ReviewDogEmitter.file_error(
1301
path: @path,
1302
message: 'This workflow is missing a Creator. Please edit this workflow in ' \
1303
'Galaxy to add the correct creator entities',
1304
code: 'GTN:024'
1305
))
1306
end
1307
results
1308
end
1309
1310
##
1311
# GTN:012 - Your bibliography is missing mandatory fields (either a URL or DOI).
1312
# GTN:031 - Your bibliography unnecessarily fills the DOI field with https://doi.org, you can just directly specify the DOI.
1313
def self.fix_bib(contents, bib)
1314
bad_keys = bib_missing_mandatory_fields(bib)
1315
results = []
1316
bad_keys.each do |key, reason|
1317
results += find_matching_texts(contents, /^\s*@.*{#{key},/)
1318
.map do |idx, text, _selected|
1319
ReviewDogEmitter.error(
1320
path: @path,
1321
idx: idx,
1322
match_start: 0,
1323
match_end: text.length,
1324
replacement: nil,
1325
message: reason,
1326
code: 'GTN:012',
1327
fn: __method__.to_s,
1328
)
1329
end
1330
end
1331
1332
# 13: doi = {https://doi.org/10.1016/j.cmpbup.2021.100007},
1333
results += find_matching_texts(contents, %r{doi\s*=\s*\{(https?://doi.org/)})
1334
.map do |idx, _text, selected|
1335
ReviewDogEmitter.warning(
1336
path: @path,
1337
idx: idx,
1338
match_start: selected.begin(1),
1339
match_end: selected.end(1) + 1,
1340
replacement: '',
1341
message: 'Unnecessary use of URL in DOI-only field, please just use the doi component itself',
1342
code: 'GTN:031'
1343
)
1344
end
1345
results
1346
end
1347
1348
@PLAIN_OUTPUT = false
1349
1350
def self.set_plain_output
1351
@PLAIN_OUTPUT = true
1352
end
1353
1354
def self.set_rdjson_output
1355
@PLAIN_OUTPUT = false
1356
end
1357
1358
@SHORT_PATH = false
1359
def self.set_short_path
1360
@SHORT_PATH = true
1361
end
1362
1363
@LIMIT_EMITTED_CODES = nil
1364
def self.code_limits(codes)
1365
@LIMIT_EMITTED_CODES = codes
1366
end
1367
1368
@AUTO_APPLY_FIXES = false
1369
def self.enable_auto_fix
1370
@AUTO_APPLY_FIXES = true
1371
end
1372
1373
def self.format_reviewdog_output(message)
1374
return if message.nil? || message.empty?
1375
return if !@LIMIT_EMITTED_CODES.nil? && !@LIMIT_EMITTED_CODES.include?(message['code']['value'])
1376
1377
1378
if !message.nil? && (message != []) && message.is_a?(Hash)
1379
path = message['location']['path']
1380
if @SHORT_PATH && path.include?(GTN_HOME + '/')
1381
path = path.gsub(GTN_HOME + '/', '')
1382
end
1383
if @PLAIN_OUTPUT # $stdout.tty? or
1384
parts = [
1385
path,
1386
message['location']['range']['start']['line'],
1387
message['location']['range']['start']['column'],
1388
message['location']['range']['end']['line'],
1389
message['location']['range']['end']['column'],
1390
"#{message['code']['value'].gsub(/:/, '')} #{message['message'].split("\n")[0]}"
1391
]
1392
puts parts.join(':')
1393
else
1394
puts JSON.generate(message)
1395
end
1396
end
1397
1398
return unless @AUTO_APPLY_FIXES && message['suggestions'].length.positive?
1399
1400
start_line = message['location']['range']['start']['line']
1401
start_coln = message['location']['range']['start']['column']
1402
end_line = message['location']['range']['end']['line']
1403
end_coln = message['location']['range']['end']['column']
1404
1405
if start_line == end_line
1406
# We only really support single-line changes. This will probs fuck up
1407
lines = File.read(message['location']['path']).split("\n")
1408
original = lines[start_line - 1].dup
1409
1410
repl = message['suggestions'][0]['text']
1411
1412
# puts "orig #{original}"
1413
# puts "before #{original[0..start_coln - 2]}"
1414
# puts "selected '#{original[start_coln-1..end_coln-2]}'"
1415
# puts "after #{original[end_coln-2..-1]}"
1416
# puts "replace: #{repl}"
1417
1418
# puts "#{original[0..start_coln - 2]} + #{repl} + #{original[end_coln-1..-1]}"
1419
fixed = original[0..start_coln - 2] + repl + original[end_coln - 1..]
1420
warn "DIFF\n-#{original}\n+#{fixed}"
1421
lines[start_line - 1] = fixed
1422
1423
# Save our changes
1424
File.write(message['location']['path'], (lines + ['']).join("\n"))
1425
else
1426
warn 'Cannot apply this suggestion sorry'
1427
end
1428
end
1429
1430
def self.emit_results(results)
1431
return unless !results.nil? && results.length.positive?
1432
1433
results.compact.flatten
1434
.select{|r| r.is_a? Hash }
1435
.each { |r| format_reviewdog_output(r) }
1436
end
1437
1438
def self.should_ignore(contents)
1439
contents.select { |x| x.match(/GTN:IGNORE:(\d\d\d)/) }.map { |x| "GTN:#{x.match(/GTN:IGNORE:(\d\d\d)/)[1]}" }.uniq
1440
end
1441
1442
def self.filter_results(results, ignores)
1443
if !results.nil?
1444
# Remove any empty lists
1445
results = results.select { |x| !x.nil? && x.length.positive? }.flatten
1446
# Before ignoring anything matching GTN:IGNORE:###
1447
return results if ignores.nil? or ignores.empty?
1448
1449
results = results.select { |x| ignores.index(x['code']['value']).nil? } if results.length.positive?
1450
return results
1451
end
1452
nil
1453
end
1454
1455
def self.fix_file(path)
1456
@path = path
1457
1458
if path.match(/\s/)
1459
emit_results([ReviewDogEmitter.file_error(path: path,
1460
message: 'There are spaces in this filename, that is forbidden.',
1461
code: 'GTN:014')])
1462
end
1463
1464
if path.match(/\?/)
1465
emit_results([ReviewDogEmitter.file_error(path: path,
1466
message: 'There ?s in this filename, that is forbidden.',
1467
code: 'GTN:014')])
1468
end
1469
1470
case path
1471
when /md$/
1472
handle = File.open(path, 'r')
1473
contents = handle.read.split("\n")
1474
ignores = should_ignore(contents)
1475
results = fix_md(contents)
1476
1477
results = filter_results(results, ignores)
1478
emit_results(results)
1479
when /.bib$/
1480
handle = File.open(path, 'r')
1481
contents = handle.read.split("\n")
1482
1483
bib = BibTeX.open(path)
1484
results = fix_bib(contents, bib)
1485
1486
results = filter_results(results, ignores)
1487
emit_results(results)
1488
when /.ga$/
1489
handle = File.open(path, 'r')
1490
begin
1491
contents = handle.read
1492
data = JSON.parse(contents)
1493
rescue StandardError => e
1494
warn "Error parsing #{path}: #{e}"
1495
emit_results([ReviewDogEmitter.file_error(path: path, message: 'Unparseable JSON in this workflow file.',
1496
code: 'GTN:019')])
1497
end
1498
1499
results = []
1500
# Check if there's a missing workflow test
1501
folder = File.dirname(path)
1502
basename = File.basename(path).gsub(/.ga$/, '')
1503
possible_tests = Dir.glob("#{folder}/#{Regexp.escape(basename)}*ym*")
1504
possible_tests = possible_tests.grep(/#{Regexp.escape(basename)}[_-]tests?.ya?ml/)
1505
1506
contains_interactive_tool = contents.match(/interactive_tool_/)
1507
1508
if possible_tests.empty?
1509
if !contains_interactive_tool
1510
results += [
1511
ReviewDogEmitter.file_error(path: path,
1512
message: 'This workflow is missing a test, which is now mandatory. Please ' \
1513
'see [the FAQ on how to add tests to your workflows](' \
1514
'https://training.galaxyproject.org/training-material/faqs/' \
1515
'gtn/gtn_workflow_testing.html).',
1516
code: 'GTN:027')
1517
]
1518
end
1519
else
1520
# Load tests and run some quick checks:
1521
possible_tests.each do |test_file|
1522
if !test_file.match(/-tests.yml/)
1523
results += [
1524
ReviewDogEmitter.file_error(path: path,
1525
message: 'Please use the extension -tests.yml ' \
1526
'for this test file.',
1527
code: 'GTN:032')
1528
]
1529
end
1530
1531
test = YAML.safe_load(File.open(test_file))
1532
test_plain = File.read(test_file)
1533
# check that for each test, the outputs is non-empty
1534
unless test.is_a?(Array)
1535
next
1536
end
1537
test.each do |test_job|
1538
if (test_job['outputs'].nil? || test_job['outputs'].empty?) && !test_plain.match(/GTN_RUN_SKIP_REASON/)
1539
results += [
1540
ReviewDogEmitter.file_error(path: path,
1541
message: 'This workflow test does not test the contents of outputs, ' \
1542
'which is now mandatory. Please see [the FAQ on how to add ' \
1543
'tests to your workflows](' \
1544
'https://training.galaxyproject.org/training-material/faqs/' \
1545
'gtn/gtn_workflow_testing.html).',
1546
code: 'GTN:030')
1547
]
1548
end
1549
end
1550
end
1551
1552
end
1553
1554
results += fix_ga_wf(data)
1555
1556
results = filter_results(results, ignores)
1557
emit_results(results)
1558
end
1559
end
1560
1561
def self.enumerate_type(filter, root_dir: 'topics')
1562
paths = []
1563
Find.find("./#{root_dir}") do |path|
1564
if FileTest.directory?(path)
1565
next unless File.basename(path).start_with?('.')
1566
1567
Find.prune # Don't look any further into this directory.
1568
1569
elsif path.match(filter)
1570
paths.push(path)
1571
end
1572
end
1573
paths
1574
end
1575
1576
def self.enumerate_symlinks
1577
paths = []
1578
Find.find('./topics') do |path|
1579
if FileTest.directory?(path)
1580
next unless File.basename(path).start_with?('.')
1581
1582
Find.prune # Don't look any further into this directory.
1583
1584
elsif File.symlink?(path)
1585
paths.push(path)
1586
end
1587
end
1588
paths
1589
end
1590
1591
def self.enumerate_lintable
1592
enumerate_type(/bib$/) + enumerate_type(/md$/) + enumerate_type(/md$/,
1593
root_dir: 'faqs') + enumerate_type(/md$/,
1594
root_dir: 'news')
1595
end
1596
1597
def self.enumerate_all
1598
enumerate_type(/.*/)
1599
end
1600
1601
##
1602
# GTN:014 - please do not use : colon in your filename.
1603
# GTN:013 - Please fix this symlink
1604
# GTN:023 - data libraries must be named data-library.yaml
1605
def self.run_linter_global
1606
enumerate_type(/:/).each do |path|
1607
format_reviewdog_output(
1608
ReviewDogEmitter.file_error(path: path,
1609
message: 'There are colons in this filename, that is forbidden.',
1610
code: 'GTN:014',
1611
fn: __method__.to_s,
1612
)
1613
)
1614
end
1615
1616
enumerate_symlinks.each do |path|
1617
if !File.exist?(Pathname.new(path).realpath)
1618
format_reviewdog_output(
1619
ReviewDogEmitter.file_error(path: path, message: 'This is a BAD symlink',
1620
code: 'GTN:013',
1621
fn: __method__.to_s)
1622
)
1623
end
1624
rescue StandardError
1625
format_reviewdog_output(
1626
ReviewDogEmitter.file_error(path: path, message: 'This is a BAD symlink', code: 'GTN:013',
1627
fn: __method__.to_s)
1628
)
1629
end
1630
enumerate_type(/data[_-]library.ya?ml/).each do |path|
1631
if path.split('/')[-1] != 'data-library.yaml'
1632
format_reviewdog_output(
1633
ReviewDogEmitter.file_error(path: path,
1634
message: 'This file must be named data-library.yaml. Please rename it.',
1635
code: 'GTN:023')
1636
)
1637
end
1638
end
1639
enumerate_type(/\.ga$/).each do |path|
1640
fix_file(path)
1641
end
1642
enumerate_lintable.each do |path|
1643
fix_file(path)
1644
end
1645
end
1646
end
1647
end
1648
1649
if $PROGRAM_NAME == __FILE__
1650
linter = Gtn::Linter
1651
1652
require 'optparse'
1653
require 'ostruct'
1654
1655
options = {}
1656
OptionParser.new do |opt|
1657
# Mutually exclusive
1658
opt.on('-f', '--format [plain|rdjson]', 'Preferred output format, defaults to plain') { |o| options[:format] = o }
1659
opt.on('-p', '--path file.md', 'Specify a single file to check instead of the entire repository') do |o|
1660
options[:path] = o
1661
end
1662
opt.on('-l', '--limit GTN:001,...', 'Limit output to specific codes') { |o| options[:limit] = o }
1663
opt.on('-a', '--auto-fix', 'I am not sure this is really safe, be careful') { |_o| options[:apply] = true }
1664
opt.on('-s', '--short-path', 'Use short path in outputs') { |_o| options[:short] = true }
1665
end.parse!
1666
1667
options[:format] = 'plain' if options[:format].nil?
1668
1669
if options[:format] == 'plain'
1670
linter.set_plain_output
1671
else
1672
linter.set_rdjson_output
1673
end
1674
1675
linter.set_short_path if options[:short]
1676
linter.code_limits(options[:limit].split(',')) if options[:limit]
1677
1678
linter.enable_auto_fix if options[:apply]
1679
1680
if options[:path].nil?
1681
linter.run_linter_global
1682
else
1683
linter.fix_file(options[:path])
1684
end
1685
end
1686
1687