Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
87 / 87
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
ValidateCommand
100.00% covered (success)
100.00%
87 / 87
100.00% covered (success)
100.00%
4 / 4
31
100.00% covered (success)
100.00%
1 / 1
 configure
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 msg
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 validateFile
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
17
 execute
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2/**
3 * "Validate" command for the changelogger tool CLI.
4 *
5 * @package automattic/jetpack-changelogger
6 */
7
8namespace Automattic\Jetpack\Changelogger;
9
10use Symfony\Component\Console\Attribute\AsCommand;
11use Symfony\Component\Console\Command\Command;
12use Symfony\Component\Console\Input\InputArgument;
13use Symfony\Component\Console\Input\InputInterface;
14use Symfony\Component\Console\Input\InputOption;
15use Symfony\Component\Console\Output\OutputInterface;
16
17/**
18 * "Validate" command for the changelogger tool CLI.
19 */
20#[AsCommand( 'validate', 'Validates changelog entry files' )]
21class ValidateCommand extends Command {
22
23    /**
24     * The default command name
25     *
26     * @var string|null
27     */
28    protected static $defaultName = 'validate';
29
30    /**
31     * The default command description
32     *
33     * @var string|null
34     */
35    protected static $defaultDescription = 'Validates changelog entry files';
36
37    /**
38     * The InputInterface to use.
39     *
40     * @var InputInterface|null
41     */
42    private $input;
43
44    /**
45     * The OutputInterface to use.
46     *
47     * @var OutputInterface|null
48     */
49    private $output;
50
51    /**
52     * Counts of errors and warnings output.
53     *
54     * @var array{error:int,warning:int}
55     */
56    private $counts;
57
58    /**
59     * Base directory regex.
60     *
61     * @var string
62     */
63    private $basedirRegex;
64
65    /**
66     * Configures the command.
67     */
68    protected function configure() {
69        $this->setDescription( static::$defaultDescription )
70            ->addOption( 'gh-action', null, InputOption::VALUE_NONE, 'Output validation issues using GitHub Action command syntax.' )
71            ->addOption( 'basedir', null, InputOption::VALUE_REQUIRED, 'Output file paths in this directory relative to it.' )
72            ->addOption( 'no-strict', null, InputOption::VALUE_NONE, 'Do not exit with a failure code if only warnings are found.' )
73            ->addArgument( 'files', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'Files to check. By default, all change files in the changelog directory are checked.' )
74            ->setHelp(
75                <<<'EOF'
76The <info>validate</info> command validates change files.
77EOF
78            );
79    }
80
81    /**
82     * Output an error or warning.
83     *
84     * @param string   $type 'error' or 'warning'.
85     * @param string   $file Filename with the error/warning.
86     * @param int|null $line Line number of the error/warning.
87     * @param string   $msg Error message.
88     */
89    private function msg( $type, $file, $line, $msg ) {
90        $file = preg_replace( $this->basedirRegex, '', $file );
91        if ( $this->input->getOption( 'gh-action' ) ) {
92            $prefix = "::$type file=$file";
93            if ( null !== $line ) {
94                $prefix .= ",line=$line";
95            }
96            $prefix .= '::';
97            $postfix = '';
98        } else {
99            $prefix = "<$type>$file";
100            if ( null !== $line ) {
101                $prefix .= ":$line";
102            }
103            $prefix .= ': ';
104            $postfix = '</>';
105        }
106        $this->output->writeln( $prefix . $msg . $postfix );
107        ++$this->counts[ $type ];
108    }
109
110    /**
111     * Validate a file.
112     *
113     * @param string $filename Filename.
114     */
115    public function validateFile( $filename ) {
116        try {
117            $diagnostics = null; // Make phpcs happy.
118            $data        = Utils::loadChangeFile( $filename, $diagnostics );
119            '@phan-var array{warnings:array{string,int}[],lines:array<string,int>} $diagnostics'; // No idea why Phan is getting confused about the type.
120        } catch ( LoadChangeFileException $ex ) {
121            $this->msg( 'error', $filename, $ex->fileLine, $ex->getMessage() );
122            return false;
123        }
124
125        $messages = array();
126
127        foreach ( $diagnostics['warnings'] as list( $msg, $line ) ) {
128            $messages[] = array( 'warning', $msg, $line );
129        }
130
131        foreach ( $diagnostics['lines'] as $header => $line ) {
132            if ( ! in_array( $header, array( 'Significance', 'Type', 'Comment', '' ), true ) ) {
133                $messages[] = array( 'warning', "Unrecognized header \"$header\".", $line );
134            }
135        }
136
137        if ( ! isset( $data['Significance'] ) ) {
138            $messages[] = array( 'error', 'File does not contain a Significance header.', null );
139        } elseif ( ! in_array( $data['Significance'], array( 'patch', 'minor', 'major' ), true ) ) {
140            $messages[] = array( 'error', 'Significance must be "patch", "minor", or "major".', $diagnostics['lines']['Significance'] );
141        } elseif ( 'patch' !== $data['Significance'] && '' === $data[''] ) {
142            $messages[] = array( 'error', 'Changelog entry may only be empty when Significance is "patch".', $diagnostics['lines'][''] );
143        }
144
145        $types = Config::types();
146        if ( $types ) {
147            if ( ! isset( $data['Type'] ) ) {
148                $messages[] = array( 'error', 'File does not contain a Type header.', null );
149            } elseif ( ! isset( $types[ $data['Type'] ] ) ) {
150                $list = array_map(
151                    function ( $v ) {
152                        return "\"$v\"";
153                    },
154                    array_keys( $types )
155                );
156                if ( count( $list ) > 1 ) {
157                    $list[ count( $list ) - 1 ] = 'or ' . $list[ count( $list ) - 1 ];
158                }
159                $messages[] = array( 'error', 'Type must be ' . implode( count( $list ) > 2 ? ', ' : ' ', $list ) . '.', $diagnostics['lines']['Type'] );
160            }
161        }
162
163        usort(
164            $messages,
165            function ( $a, $b ) {
166                // @codeCoverageIgnoreStart
167                $ret = $a[2] <=> $b[2];
168                if ( ! $ret ) {
169                    $ret = strcmp( $a[0], $b[0] );
170                }
171                if ( ! $ret ) {
172                    $ret = strcmp( $a[1], $b[1] );
173                }
174                return $ret;
175                // @codeCoverageIgnoreEnd
176            }
177        );
178        foreach ( $messages as list( $type, $msg, $line ) ) {
179            $this->msg( $type, $filename, $line, $msg );
180        }
181    }
182
183    /**
184     * Executes the command.
185     *
186     * @param InputInterface  $input InputInterface.
187     * @param OutputInterface $output OutputInterface.
188     * @return int 0 if everything went fine, or an exit code.
189     */
190    protected function execute( InputInterface $input, OutputInterface $output ): int {
191        $this->input  = $input;
192        $this->output = $output;
193        $this->counts = array(
194            'error'   => 0,
195            'warning' => 0,
196        );
197
198        if ( $input->getOption( 'basedir' ) ) {
199            $basedir            = rtrim( $input->getOption( 'basedir' ), '/' );
200            $basedir            = rtrim( $basedir, DIRECTORY_SEPARATOR );
201            $this->basedirRegex = '#^' . preg_quote( $basedir, '#' ) . '[/' . preg_quote( DIRECTORY_SEPARATOR, '#' ) . ']#';
202        } else {
203            $this->basedirRegex = '/(?!)/';
204        }
205
206        $files = $input->getArgument( 'files' );
207        if ( ! $files ) {
208            $files = array();
209            foreach ( new \DirectoryIterator( Config::changesDir() ) as $file ) {
210                $name = $file->getBasename();
211                if ( '.' !== $name[0] ) {
212                    $files[] = $file->getPathname();
213                }
214            }
215            sort( $files );
216        }
217
218        foreach ( $files as $filename ) {
219            $file = preg_replace( $this->basedirRegex, '', $filename );
220            $output->writeln( "Checking $file...", OutputInterface::VERBOSITY_VERBOSE );
221            $this->validateFile( $filename );
222        }
223
224        $output->writeln( sprintf( 'Found %d error(s) and %d warning(s)', $this->counts['error'], $this->counts['warning'] ), OutputInterface::VERBOSITY_VERBOSE );
225        // @phan-suppress-next-line PhanImpossibleCondition -- Phan is incorrectly assuming it never changes.
226        return $this->counts['error'] || $this->counts['warning'] && ! $input->getOption( 'no-strict' ) ? 1 : 0;
227    }
228}