diff options
author | Yves Orton <demerphq@gmail.com> | 2022-08-11 16:02:57 +0200 |
---|---|---|
committer | Yves Orton <demerphq@gmail.com> | 2022-08-21 12:09:05 +0200 |
commit | 40c26f0c37ae90c90b3db1887343c857d0f78d49 (patch) | |
tree | acd5ed5e237e5fc74cb4a0cd24ca40f06ab21047 | |
parent | 921bc9a5068bda5c083f97cd2fd08273db3870a9 (diff) | |
download | perl-40c26f0c37ae90c90b3db1887343c857d0f78d49.tar.gz |
updateAUTHORS.p[lm] - add support for reports like checkAUTHORS.pl has
Adds the --stats, --files, --who, and related options similar to
what checkAUTHORS.pl offers. See perldoc Porting/updateAUTHORS.pl for
the list of options it supports.
-rwxr-xr-x | Porting/updateAUTHORS.pl | 170 | ||||
-rw-r--r-- | Porting/updateAUTHORS.pm | 240 | ||||
-rw-r--r-- | t/porting/update_authors.t | 164 |
3 files changed, 572 insertions, 2 deletions
diff --git a/Porting/updateAUTHORS.pl b/Porting/updateAUTHORS.pl index 0e41bb01a9..8547811546 100755 --- a/Porting/updateAUTHORS.pl +++ b/Porting/updateAUTHORS.pl @@ -26,8 +26,32 @@ my @OPTSPEC= qw( exclude_contrib=s@ exclude_me + show_rank|rank + show_applied|thanks_applied|applied + show_stats|stats + show_who|who + show_files|files + show_file_changes|activity + show_file_chainsaw|chainsaw + + as_percentage|percentage + as_cumulative|cumulative + as_list|old_style + + in_reverse|reverse + with_rank_numbers|numbered|num + from_commit|from=s to_commit|to=s + + numstat + no_update +); + +my %implies_numstat= ( + show_files => 1, + show_file_changes => 1, + show_file_chainsaw => 1, ); sub main { @@ -63,6 +87,11 @@ sub main { pod2usage(1) if $opts{help}; pod2usage(-verbose => 2) if $opts{man}; + foreach my $opt (keys %opts) { + $opts{numstat}++ if $implies_numstat{$opt}; + $opts{no_update}++ if $opt =~ /^show_/; + } + if (delete $opts{exclude_me}) { my ($author_full)= Porting::updateAUTHORS->current_author_name_email("full"); @@ -81,6 +110,48 @@ sub main { my $changed= $self->read_and_update(); + if ($self->{show_rank}) { + $self->report_stats("who_stats", "author"); + return 0; + } + elsif ($self->{show_applied}) { + $self->report_stats("who_stats", "applied"); + return 0; + } + elsif ($self->{show_stats}) { + my @fields= ("author", "applied", "committer"); + push @fields, + ("num_files", "lines_added", "lines_removed", "lines_delta") + if $self->{numstat}; + $self->report_stats("who_stats", @fields); + return 0; + } + elsif ($self->{show_files}) { + $self->report_stats( + "file_stats", "commits", "lines_added", "lines_removed", + "lines_delta", "binary_change" + ); + return 0; + } + elsif ($self->{show_file_changes}) { + $self->report_stats( + "file_stats", "lines_delta", "lines_added", "lines_removed", + "commits" + ); + return 0; + } + elsif ($self->{show_file_chainsaw}) { + $self->{in_reverse}= !$self->{in_reverse}; + $self->report_stats( + "file_stats", "lines_delta", "lines_added", "lines_removed", + "commits" + ); + return 0; + } + elsif ($self->{show_who}) { + $self->print_who(); + return 0; + } return $changed; # 0 means nothing changed } @@ -116,6 +187,7 @@ are properly listed. --mailmap-file=FILE override default of '.mailmap' Action Modifiers + --no-update Do not update. --exclude-missing Add new names to the exclude file so they never appear in AUTHORS or .mailmap. @@ -124,6 +196,27 @@ are properly listed. --exclude-contrib NAME_AND_EMAIL --exclude-me + Reports About People + --stats detailed report of authors and what they did + --who Sorted, wrapped list of who did what + --thanks-applied report who applied stuff for others + --rank report authors by number of commits created + + Reports About Files + --files detailed report files that were modified + --activity simple report of files that grew the most + --chainsaw simple report of files that shrank the most + + Report Modifiers + --percentage show percentages not counts + --cumulative show cumulative numbers not individual + --reverse show reports in reverse order + --numstat show additional file based data in some reports + (not needed for most reports) + --as-list show reports with names with common values + folded into a list like checkAUTHORS.pl used to + --numbered add rank numbers to reports where they are missing + =head1 OPTIONS =over 4 @@ -140,6 +233,10 @@ Prints the manual page and exits. Be verbose about what is happening. Can be repeated more than once. +=item C<--no-update> + +Do not update files on disk even if they need to be changed. + =item C<--authors-file=FILE> =item C<--authors_file=FILE> @@ -208,6 +305,79 @@ tool with the C<--exclude> option. It is probably a good idea to run it first without any arguments to make sure you dont exclude something or someone you did not intend to. +=item C<--stats> + +Show detailed stats about committers and the work they did in a tabular +form. If the C<--numstat> option is provided this report will provide +additional data about the files a developer worked on. May be slow the +first time it is used as git unpacks the relevant data. + +=item C<--who> + +Show a list of which committers and authors contributed to the project +in the selected range of commits. The list will contain the name only, +and will sorted according to unicode collation rules. This list is +suitable in release notes and similar contexts. + +=item C<--thanks-applied> + +Show a report of which committers applied work on behalf of +someone else, including counts. Modified by the C<--as-list> and +C<--display-rank>. + +=item C<--rank> + +Shows a report of which commits did the most work. Modified by the +C<--as-list> and C<--display-rank> options. + +=item C<--files> + +Show detailed stats about the files that have been modified in the +selected range of commits. Implies C<--numstat>. May be slow the first +time it is used as git unpacks the relevant data. + +=item C<--activity> + +Show simple stats about which files had the most additions. Implies +C<--numstat>. May be slow the first time it is used as git unpacks the +relevant data. + + +=item C<--chainsaw> + +Show simple stats about whcih files had the most removals. Implies +C<--numstat>. May be slow the first time it is used as git unpacks the +relevant data. + +=item C<--percentage> + +Show numeric data as percentages of the total, not counts. + +=item C<--cumulative> + +Show numeric data as cumulative counts in the reports. + +=item C<--reverse> + +Show the reports in reverse order to normal. + +=item C<--numstat> + +Gather additional data about the files that were changed, not just the +authors who did the changes. This option currently is only necessary for +the C<--stats> option, which will display additional data when this +option is also provided. + +=item C<--as-list> + +Show the reports with name data rolled up together into a list like the +older checkAUTHORS.pl script would have. + +=item C<--numbered> + +Show an additional column with the rank number of a row in the report in +reports that do not normally show the rank number. + =back =head1 DESCRIPTION diff --git a/Porting/updateAUTHORS.pm b/Porting/updateAUTHORS.pm index 835b6b4bbd..3cefe69bef 100644 --- a/Porting/updateAUTHORS.pm +++ b/Porting/updateAUTHORS.pm @@ -4,6 +4,9 @@ use warnings; use Data::Dumper; use Encode qw(encode_utf8 decode_utf8 decode); use Digest::SHA qw(sha256_base64); +use Text::Wrap qw(wrap); +use Unicode::Collate; +$Text::Wrap::columns= 80; # The style of this file is determined by: # @@ -39,9 +42,10 @@ my %field_spec= ( "s" => "commit_subject", ); +my $Collate= Unicode::Collate->new(level => 1, indentical => 1); my @field_codes= sort keys %field_spec; my @field_names= map { $field_spec{$_} } @field_codes; -my $tformat= join "%x00", map { "%" . $_ } @field_codes; +my $tformat= "=" . join "%x00", map { "%" . $_ } @field_codes; sub _make_name_author_info { my ($self, $commit_info, $name_key)= @_; @@ -83,6 +87,8 @@ sub _register_author { my $digest= $self->_keeper_digest($name) or return; + $self->{who_stats}{$name}{$type}++; + $self->{author_info}{"lines"}{$name} and return; @@ -147,6 +153,46 @@ sub current_author_name_email { return $full ? $self->format_name_email($n, $e) : ($n, $e); } +sub finalize_commit_info { + my ($self, $commit_info)= @_; + my $author= $commit_info->{author_name_mm_canon}; + my $author_stats= $self->{who_stats}{$author} ||= {}; + + my $file_info= $commit_info->{files} ||= {}; + foreach my $file (keys %{$file_info}) { + if (!$self->{file_stats}{$file}) { + $self->{summary_stats}{num_files}++; + } + my $fs= $self->{file_stats}{$file} ||= {}; + my $afs= $author_stats->{file_stats}{$file} ||= {}; + my $added= $file_info->{$file}{lines_added}; + my $removed= $file_info->{$file}{lines_removed}; + my $delta= $file_info->{$file}{lines_delta}; + defined $_ and $_ eq "-" and undef $_ for $added, $removed; + + if (defined $added) { + for my $h ($author_stats, $fs, $afs) { + $h->{lines_delta} += $delta; + $h->{lines_added} += $added; + $h->{lines_removed} += $removed; + } + } + else { + $author_stats->{binary_change}++; + $fs->{binary_change}++; + $afs->{binary_change}++; + } + $afs->{commits}++ + or $author_stats->{num_files}++; + + $fs->{commits}++ + or $self->{summary_stats}{num_files}++; + + $fs->{who}{$author}++ + or $self->{summary_stats}{authors}++; + } +} + sub read_commit_log { my ($self)= @_; my $author_info= $self->{author_info} ||= {}; @@ -155,13 +201,39 @@ sub read_commit_log { my $commit_range= $self->{commit_range}; my $commits_read= 0; - my $cmd= qq(git log --pretty='format:$tformat' $commit_range); + my $numstat= $self->{numstat} ? "--numstat" : ""; + + my $last_commit_info; + my $cmd= qq(git log --pretty='format:$tformat' $numstat $commit_range); open my $fh, "$cmd |"; while (defined(my $line= <$fh>)) { chomp $line; $line= decode_utf8($line); + if ($line =~ s/^=//) { + $self->finalize_commit_info($last_commit_info) + if $last_commit_info; + } + elsif ($line =~ /\S/) { + my ($added, $removed, $file)= split /\s+/, $line; + if ($added ne "-") { + $last_commit_info->{files}{$file}= { + lines_added => $added, + lines_removed => $removed, + lines_delta => $added - $removed, + }; + } + else { + $last_commit_info->{files}{$file}{binary_changes}++; + } + next; + } + else { + # whitspace only or empty line + next; + } $commits_read++; my $commit_info= {}; + $last_commit_info= $commit_info; @{$commit_info}{@field_names}= split /\0/, $line, 0 + @field_names; my $author_name_mm_canon= @@ -175,12 +247,15 @@ sub read_commit_log { my $committer_name_real= $self->_make_name_simple($commit_info, "committer"); + my ($author_good, $committer_good); + if ( $self->_keeper_digest($author_name_mm_canon) && $self->_keeper_digest($author_name_real)) { $self->_check_name_mailmap($author_name_mm_canon, $author_name_real, $commit_info, "author name"); $self->_register_author($author_name_mm_canon, "author"); + $author_good= 1; } if ( $self->_keeper_digest($committer_name_mm_canon) @@ -189,8 +264,16 @@ sub read_commit_log { $self->_check_name_mailmap($committer_name_mm_canon, $committer_name_real, $commit_info, "committer name"); $self->_register_author($committer_name_mm_canon, "committer"); + $committer_good= 1; + } + if ( $author_good + and $committer_good + and $committer_name_mm_canon ne $author_name_mm_canon) + { + $self->{who_stats}{$committer_name_mm_canon}{applied}++; } } + $self->finalize_commit_info($last_commit_info) if $last_commit_info; if (!$commits_read) { if ($self->{commit_range}) { die "No commits in range '$self->{commit_range}'\n"; @@ -727,6 +810,11 @@ sub update_exclude_file { if ($exclude_text ne $self->{exclude_file_text_orig}) { $self->{changed_count}++; $self->{changed_file}{$exclude_file}++; + + if ($self->{no_update}) { + return 1; + } + warn "Updating '$exclude_file'\n" if $self->{verbose}; my $tmp_file= "$exclude_file.new"; @@ -835,6 +923,154 @@ sub _exclude_contrib { or warn "Excluding '$name' with '$digest'\n"; } +my %pretty_name= ( + "author" => "Authored", + "committer" => "Committed", + "applied" => "Applied", + "name" => "Name", + "pos" => "Pos", + "num_files" => "NFiles", + "lines_added" => "L++", + "lines_removed" => "L--", + "lines_delta" => "L+-", + "binary_changed" => "Bin+-", +); + +sub report_stats { + my ($self, $stats_key, @types)= @_; + my @extra= "name"; + my @rows; + my @total; + foreach my $name (__sorted_hash_keys($self->{$stats_key})) { + my @data= map { $self->{$stats_key}{$name}{$_} // 0 } @types; + $total[$_] += $data[$_] for 0 .. $#data; + push @data, $name; + push @rows, \@data if $data[0]; + } + @rows= sort { + my $cmp= 0; + for (0 .. $#$a - 1) { + $cmp= $b->[$_] <=> $a->[$_]; + last if $cmp; + } + $cmp ||= $Collate->cmp($a->[-1], $b->[-1]); + $cmp + } @rows; + @rows= reverse @rows if $self->{in_reverse}; + + if ($self->{as_cumulative}) { + my $sum= []; + for my $row (@rows) { + do { + $sum->[$_] += $row->[$_]; + $row->[$_]= $sum->[$_]; + } + for 0 .. $#types; + } + } + + if ($self->{as_percentage}) { + for my $row (@rows) { + $row->[$_]= sprintf "%.2f", ($row->[$_] / $total[$_]) * 100 + for 0 .. $#types; + } + } + + foreach my $row (@rows) { + my $name= $row->[-1]; + $name =~ s/\s+<.*\z//; + $name =~ s/\s+\@.*\z//; + $row->[-1]= $name; + } + my @col_names= map { $pretty_name{$_} // $_ } @types; + if ($self->{as_percentage}) { + $_= "%$_" for @col_names; + } + push @col_names, map { $pretty_name{$_} // $_ } @extra; + + if ($self->{as_list} && @types == 1) { + $self->_report_list(\@rows, \@types, \@extra, \@col_names); + } + else { + $self->_report_table(\@rows, \@types, \@extra, \@col_names); + } +} + +sub _report_table { + my ($self, $rows, $types, $extra, $col_names)= @_; + my $pos= 1; + unshift @$_, $pos++ for @$rows; + unshift @$col_names, "Pos"; + my @width= (0) x @$col_names; + foreach my $row ($col_names, @$rows) { + for my $idx (0 .. $#$row) { + $width[$idx] < length($row->[$idx]) + and $width[$idx]= length($row->[$idx]); + } + } + $width[-1]= 40 if $width[-1] > 40; + $width[$_]= -$width[$_] for 0, -1; + my $fmt= "#" . join(" | ", ("%*s") x @$col_names) . "\n"; + my $bar_fmt= "#" . join("-+-", ("%*s") x @$col_names) . "\n"; + printf $fmt, map { $width[$_], $col_names->[$_] } 0 .. $#width; + printf $bar_fmt, map { $width[$_], "-" x abs($width[$_]) } 0 .. $#width; + for my $idx (0 .. $#$rows) { + my $row= $rows->[$idx]; + print encode_utf8 sprintf $fmt, + map { $width[$_], $row->[$_] } 0 .. $#width; + } +} + +sub _report_list { + my ($self, $rows, $types, $extra, $col_names)= @_; + my %hash; + foreach my $row (@$rows) { + $hash{ $row->[0] }{ $row->[-1] }++; + } + my @vals= sort { $b <=> $a } keys %hash; # numeric sort + my $width= length($col_names->[0]); + $width < length($_) and $width= length($_) for @vals; + @vals= reverse @vals if $self->{in_reverse}; + + my $hdr_str= sprintf "%*s | %s", $width, $col_names->[0], $col_names->[-1]; + my $sep_str= sprintf "%*s-+-%s", $width, "-" x $width, "-" x 40; + my $fmt= "%*s | %s"; + + if ($self->{with_rank_numbers}) { + $hdr_str= sprintf "#%*s | %s", -length(0 + @$rows), "Pos", $hdr_str; + $sep_str= sprintf "#%*s-+-%s", -length(0 + @$rows), + "-" x length(0 + @$rows), $hdr_str; + } + print $hdr_str, "\n"; + print $sep_str, "\n"; + my $pos= 1; + foreach my $val (@vals) { + my $val_f= sprintf "%*s | ", $width, $val; + $val_f= sprintf "#%*d | %s", -length(0 + @$rows), $pos++, $val_f + if $self->{with_rank_numbers}; + print encode_utf8 wrap $val_f, + " " x length($val_f), + join(", ", $Collate->sort(keys %{ $hash{$val} })) . "\n"; + } +} + +sub _filter_sort_who { + my ($self, $hash)= @_; + my @who; + foreach my $name ($Collate->sort(keys %$hash)) { + $name =~ s/\s+<.*\z//; + $name =~ s/\s+\@.*\z//; + push @who, $name if length $name and lc($name) ne "unknown"; + } + return @who; +} + +sub print_who { + my ($self)= @_; + my @who= $self->_filter_sort_who($self->{who_stats}); + print encode_utf8 wrap "", "", join(", ", @who) . ".\n"; +} + 1; __END__ diff --git a/t/porting/update_authors.t b/t/porting/update_authors.t index 9bd76a9671..589deeb3f7 100644 --- a/t/porting/update_authors.t +++ b/t/porting/update_authors.t @@ -15,5 +15,169 @@ my $ok= do "./Porting/updateAUTHORS.pl"; my $error= !$ok && $@; is($ok,1,"updateAUTHORS.pl compiles correctly"); is($error, "", "updateAUTHORS.pl compiles without error"); +my $small_range= "544171f79ec3e50bb5003007e9f4ebb9a7e9fe84^^^" + . "..544171f79ec3e50bb5003007e9f4ebb9a7e9fe84"; +my $large_range= "6d02a9e121d037896df9b91ac623c1ab4c98c99a.." + . "544171f79ec3e50bb5003007e9f4ebb9a7e9fe8"; +my $with_unknown_range= "96a91e01636d3050d38ae3373a362c7d47a6647e^^^.." + . "96a91e01636d3050d38ae3373a362c7d47a6647e"; +foreach my $tuple ( + [ "--who", $small_range, + "James E Keenan, Karl Williamson, Mark Shelor." ], + [ "--files", $small_range, files_expected() ], + [ "--rank", $large_range, rank_expected()], + [ "--rank --percentage", $large_range, rank_percentage_expected()], + [ "--rank --percentage --cumulative", $large_range, + rank_percentage_cumulative_expected()], + [ "--thanks-applied", $large_range, thanks_applied_expected() ], + [ "--stats", $large_range, stats_expected() ], + [ "--stats --numstat", $large_range, stats_numstat_expected() ], + [ "--who" , $with_unknown_range, "Jarkko Hietaniemi.", "(no 'unknown' authors)" ], +) { + my ($arg,$range,$expect, $msg_extra)= @$tuple; + my $parsed= `git rev-parse --verify -q $range`; + SKIP: { + unless ($parsed) { + skip "commit range '$range' not available (this happens in CI)", 1; + } + $msg_extra= $msg_extra ? " $msg_extra" : ""; + my $cmd= join " ", "$^X ./Porting/updateAUTHORS.pl", + $arg, $range; + my $result= `$cmd`; + is(_clean($result), _clean($expect),"Option '$arg' works as expected$msg_extra") + or print STDERR "$cmd\n",$result + } +} done_testing(); exit 0; +sub _clean { + my ($str)= @_; + $str=~s/\s+\z//; + $str=~s/[ ]+\n/\n/g; + return $str; +} + +sub files_expected { + return <<'END_OF_REPORT'; +#Pos | commits | L++ | L-- | L+- | binary_change | Name +#----+---------+-----+-----+-----+---------------+---------------------------------- +#1 | 1 | 28 | 0 | 28 | 0 | pod/perlfunc.pod +#2 | 1 | 14 | 4 | 10 | 0 | cpan/Digest-SHA/lib/Digest/SHA.pm +#3 | 1 | 5 | 5 | 0 | 0 | cpan/Digest-SHA/shasum +#4 | 1 | 3 | 3 | 0 | 0 | cpan/Digest-SHA/src/sha64bit.c +#5 | 1 | 3 | 3 | 0 | 0 | cpan/Digest-SHA/src/sha64bit.h +#6 | 1 | 3 | 3 | 0 | 0 | cpan/Digest-SHA/src/sha.c +#7 | 1 | 3 | 3 | 0 | 0 | cpan/Digest-SHA/src/sha.h +#8 | 1 | 1 | 1 | 0 | 0 | Porting/Maintainers.pl +#9 | 1 | 1 | 0 | 1 | 0 | AUTHORS +END_OF_REPORT +} + +sub rank_expected { + return <<'END_OF_REPORT'; +#Pos | Authored | Name +#----+----------+----------------- +#1 | 40 | Karl Williamson +#2 | 32 | Yves Orton +#3 | 8 | Paul Evans +#4 | 6 | James E Keenan +#5 | 4 | Elvin Aslanov +#6 | 3 | Richard Leach +#7 | 3 | Tony Cook +#8 | 2 | Nicholas Clark +#9 | 1 | Dan Kogai +#10 | 1 | David Golden +#11 | 1 | Graham Knop +#12 | 1 | Mark Shelor +#13 | 1 | Tomasz Konojacki +END_OF_REPORT +} + +sub rank_percentage_expected { + return <<'END_OF_REPORT'; +#Pos | %Authored | Name +#----+-----------+----------------- +#1 | 38.83 | Karl Williamson +#2 | 31.07 | Yves Orton +#3 | 7.77 | Paul Evans +#4 | 5.83 | James E Keenan +#5 | 3.88 | Elvin Aslanov +#6 | 2.91 | Richard Leach +#7 | 2.91 | Tony Cook +#8 | 1.94 | Nicholas Clark +#9 | 0.97 | Dan Kogai +#10 | 0.97 | David Golden +#11 | 0.97 | Graham Knop +#12 | 0.97 | Mark Shelor +#13 | 0.97 | Tomasz Konojacki +END_OF_REPORT +} + +sub rank_percentage_cumulative_expected { + return <<'END_OF_REPORT'; +#Pos | %Authored | Name +#----+-----------+----------------- +#1 | 38.83 | Karl Williamson +#2 | 69.90 | Yves Orton +#3 | 77.67 | Paul Evans +#4 | 83.50 | James E Keenan +#5 | 87.38 | Elvin Aslanov +#6 | 90.29 | Richard Leach +#7 | 93.20 | Tony Cook +#8 | 95.15 | Nicholas Clark +#9 | 96.12 | Dan Kogai +#10 | 97.09 | David Golden +#11 | 98.06 | Graham Knop +#12 | 99.03 | Mark Shelor +#13 | 100.00 | Tomasz Konojacki +END_OF_REPORT +} + +sub thanks_applied_expected { + return <<'END_OF_REPORT'; +#Pos | Applied | Name +#----+---------+---------------- +#1 | 7 | Karl Williamson +#2 | 4 | James E Keenan +END_OF_REPORT +} + +sub stats_expected { + return <<'END_OF_REPORT'; +#Pos | Authored | Applied | Committed | Name +#----+----------+---------+-----------+----------------- +#1 | 40 | 7 | 47 | Karl Williamson +#2 | 32 | 0 | 31 | Yves Orton +#3 | 8 | 0 | 8 | Paul Evans +#4 | 6 | 4 | 10 | James E Keenan +#5 | 4 | 0 | 1 | Elvin Aslanov +#6 | 3 | 0 | 3 | Tony Cook +#7 | 3 | 0 | 0 | Richard Leach +#8 | 2 | 0 | 2 | Nicholas Clark +#9 | 1 | 0 | 1 | Tomasz Konojacki +#10 | 1 | 0 | 0 | Dan Kogai +#11 | 1 | 0 | 0 | David Golden +#12 | 1 | 0 | 0 | Graham Knop +#13 | 1 | 0 | 0 | Mark Shelor +END_OF_REPORT +} + +sub stats_numstat_expected { + return <<'END_OF_REPORT'; +#Pos | Authored | Applied | Committed | NFiles | L++ | L-- | L+- | Name +#----+----------+---------+-----------+--------+------+------+------+----------------- +#1 | 40 | 7 | 47 | 14 | 1179 | 874 | 305 | Karl Williamson +#2 | 32 | 0 | 31 | 25 | 2547 | 1481 | 1066 | Yves Orton +#3 | 8 | 0 | 8 | 15 | 161 | 102 | 59 | Paul Evans +#4 | 6 | 4 | 10 | 4 | 44 | 11 | 33 | James E Keenan +#5 | 4 | 0 | 1 | 4 | 16 | 13 | 3 | Elvin Aslanov +#6 | 3 | 0 | 3 | 7 | 8 | 7 | 1 | Tony Cook +#7 | 3 | 0 | 0 | 13 | 75 | 51 | 24 | Richard Leach +#8 | 2 | 0 | 2 | 2 | 24 | 1 | 23 | Nicholas Clark +#9 | 1 | 0 | 1 | 2 | 21 | 15 | 6 | Tomasz Konojacki +#10 | 1 | 0 | 0 | 8 | 33 | 22 | 11 | Mark Shelor +#11 | 1 | 0 | 0 | 5 | 93 | 7 | 86 | Graham Knop +#12 | 1 | 0 | 0 | 4 | 9 | 4 | 5 | Dan Kogai +#13 | 1 | 0 | 0 | 2 | 19 | 6 | 13 | David Golden +END_OF_REPORT +} |