Skip to content

Commit b86b3ef

Browse files
committed
t: add check-grep to detect bare grep assertions
Add check-grep.pl, a lint tool that detects bare 'grep' used as a test assertion where 'test_grep' should be used instead, and '! test_grep' where 'test_grep !' should be used (shell negation suppresses the diagnostic output). Wire it into the test-lint target as test-lint-grep. The checker reuses chainlint's shared shell parser to correctly identify command boundaries, so 'grep' appearing as an argument to another command, in heredoc bodies, command substitutions, or strings is not flagged. Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>
1 parent d2681f7 commit b86b3ef

2 files changed

Lines changed: 145 additions & 1 deletion

File tree

t/Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ check-meson:
139139
test-lint: test-lint-duplicates test-lint-executable \
140140
test-lint-filenames
141141
ifneq ($(PERL_PATH),)
142-
test-lint: test-lint-shell-syntax
142+
test-lint: test-lint-shell-syntax test-lint-grep
143143
else
144144
GIT_TEST_CHAIN_LINT = 0
145145
endif
@@ -157,6 +157,9 @@ test-lint-executable:
157157
test -z "$$bad" || { \
158158
echo >&2 "non-executable tests:" $$bad; exit 1; }
159159

160+
test-lint-grep:
161+
@'$(PERL_PATH_SQ)' check-grep.pl $(T) $(THELPERS) $(TPERF)
162+
160163
test-lint-shell-syntax:
161164
@'$(PERL_PATH_SQ)' check-non-portable-shell.pl $(T) $(THELPERS) $(TPERF)
162165

t/check-grep.pl

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#!/usr/bin/perl
2+
3+
# Detect bare 'grep' used as a test assertion where 'test_grep'
4+
# should be used, and '! test_grep' where 'test_grep !' should
5+
# be used. Uses the shared shell parser to correctly identify
6+
# command boundaries, avoiding false positives from 'grep' in
7+
# arguments, strings, heredocs, or command substitutions.
8+
9+
use strict;
10+
use warnings;
11+
use File::Basename;
12+
13+
my $_lib = dirname($0) . "/lib-shell-parser.pl";
14+
$_lib = "./$_lib" unless $_lib =~ m{^/};
15+
do $_lib or die "$0: failed to load $_lib: $@$!\n";
16+
17+
my $exit_code = 0;
18+
19+
package CheckGrepParser;
20+
our @ISA = ('ScriptParser');
21+
22+
# Tokens that mark the start of a new command.
23+
my %cmd_start;
24+
@cmd_start{qw(&& || ;; do then else elif), "\n", '{', '('} = ();
25+
26+
# Subset of cmd_start that also ends a pipeline.
27+
my %pipe_end;
28+
@pipe_end{qw(&& || ;;)} = ();
29+
30+
# Tokens indicating grep is used as a filter (pipe or redirect).
31+
my %filter_op;
32+
@filter_op{'|', '>', '>>', '<'} = ();
33+
34+
# Check if the raw source line has a lint-ok annotation.
35+
# Also check adjacent lines to handle backslash-continuation
36+
# line number offsets from the parser.
37+
sub lint_ok {
38+
my ($raw_lines, $ln) = @_;
39+
for my $l ($ln - 1, $ln, $ln + 1) {
40+
next if $l < 1 || $l > @$raw_lines;
41+
return 1 if $raw_lines->[$l - 1] =~ /lint-ok/;
42+
}
43+
return 0;
44+
}
45+
46+
# Check if the grep at position $pos pipes its output
47+
# or redirects I/O (i.e. used as a filter, not assertion).
48+
sub is_filter {
49+
my ($tokens, $pos) = @_;
50+
for (my $j = $pos + 1; $j < @$tokens; $j++) {
51+
my $t = $tokens->[$j]->[0];
52+
return 0 if exists $cmd_start{$t};
53+
return 1 if exists $filter_op{$t};
54+
}
55+
return 0;
56+
}
57+
58+
sub check_test {
59+
my $self = shift @_;
60+
my $title = ScriptParser::unwrap(shift @_);
61+
my $body = shift @_;
62+
my $lineno = $body->[3];
63+
$body = ScriptParser::unwrap($body);
64+
if ($body eq '-') {
65+
my $herebody = shift @_;
66+
if ($herebody) {
67+
$body = $herebody->{content};
68+
$lineno = $herebody->{start_line};
69+
}
70+
}
71+
return unless $body;
72+
73+
my $parser = ShellParser->new(\$body);
74+
my @tokens = $parser->parse();
75+
76+
my $raw_lines = $self->{raw_lines};
77+
my $file = $self->{file};
78+
my $cmd_pos = 1;
79+
my $in_pipe = 0;
80+
81+
for (my $i = 0; $i < @tokens; $i++) {
82+
my $text = $tokens[$i]->[0];
83+
my $ln = ($tokens[$i]->[3] || 0) + ($lineno || 1) - 1;
84+
85+
if (exists $cmd_start{$text}) {
86+
$cmd_pos = 1;
87+
$in_pipe = 0 if exists $pipe_end{$text};
88+
next;
89+
}
90+
if ($text eq '|') {
91+
$cmd_pos = 1;
92+
$in_pipe = 1;
93+
next;
94+
}
95+
if ($text eq '}' || $text eq ')') {
96+
$cmd_pos = 0;
97+
next;
98+
}
99+
100+
next unless $cmd_pos;
101+
102+
# '!' is a negation prefix, stay in command position
103+
if ($text eq '!') {
104+
if ($i + 1 < @tokens &&
105+
$tokens[$i + 1]->[0] eq 'test_grep' &&
106+
!lint_ok($raw_lines, $ln)) {
107+
print "$file:$ln: error: ",
108+
'use "test_grep !" instead of ',
109+
'"! test_grep"', "\n";
110+
$exit_code = 1;
111+
}
112+
next;
113+
}
114+
115+
# bare grep at command position, not in a pipeline
116+
if ($text eq 'grep' && !$in_pipe &&
117+
!is_filter(\@tokens, $i) &&
118+
!lint_ok($raw_lines, $ln)) {
119+
print "$file:$ln: error: ",
120+
"bare grep outside pipeline ",
121+
"(use test_grep)\n";
122+
$exit_code = 1;
123+
}
124+
125+
$cmd_pos = 0;
126+
}
127+
}
128+
129+
package main;
130+
131+
for my $file (@ARGV) {
132+
open(my $fh, '<:unix:crlf', $file) or die "$0: $file: $!\n";
133+
my @raw_lines = <$fh>;
134+
close $fh;
135+
my $s = join('', @raw_lines);
136+
my $parser = CheckGrepParser->new(\$s);
137+
$parser->{file} = $file;
138+
$parser->{raw_lines} = \@raw_lines;
139+
$parser->parse();
140+
}
141+
exit $exit_code;

0 commit comments

Comments
 (0)