diff --git a/src/commands/check.ts b/src/commands/check.ts index c74f1b8..df4beb2 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import { getConfig } from '../config.ts'; declare const BUILTIN_CHECK_SLUGS: string[]; +import { executeTbdCheck } from './checks/tbd.ts'; import { runCommand } from '../utils/process.ts'; import * as output from '../utils/output.ts'; import type { CheckConfig, CheckResult } from '../types.ts'; @@ -97,19 +98,21 @@ export async function runChecks(options: { * @since TBD * * @param {string} slug - The identifier for the built-in check. - * @param {CheckConfig} _checkConfig - The configuration for this check. - * @param {string} _cwd - The current working directory. + * @param {CheckConfig} checkConfig - The configuration for this check. + * @param {string} cwd - The current working directory. * @param {ReturnType} _config - The resolved pup configuration. * * @returns {Promise} The result of the check. */ async function runBuiltinCheck( slug: string, - _checkConfig: CheckConfig, - _cwd: string, + checkConfig: CheckConfig, + cwd: string, _config: ReturnType ): Promise { switch (slug) { + case 'tbd': + return executeTbdCheck(checkConfig, cwd); default: return { success: false, output: `Unknown built-in check: ${slug}` }; } diff --git a/src/commands/checks/tbd.ts b/src/commands/checks/tbd.ts new file mode 100644 index 0000000..676232b --- /dev/null +++ b/src/commands/checks/tbd.ts @@ -0,0 +1,141 @@ +import chalk from 'chalk'; +import fs from 'fs-extra'; +import path from 'node:path'; +import * as output from '../../utils/output.ts'; +import type { CheckConfig, CheckResult } from '../../types.ts'; + +const DEFAULT_SKIP_DIRS = 'bin|build|vendor|node_modules|.git|.github|tests'; +const DEFAULT_SKIP_FILES = '.min.css|.min.js|.map.js|.css|.png|.jpg|.jpeg|.svg|.gif|.ico'; +const DEFAULT_DIRS = ['src']; + +interface TbdMatch { + file: string; + line: number; + content: string; +} + +/** + * Scans configured directories for TBD markers. + * + * @since TBD + * + * @param {CheckConfig} config - The check configuration containing directories and skip patterns. + * @param {string} workingDir - The working directory to scan relative to. + * + * @returns {Promise} A CheckResult indicating success or failure with details about found TBDs. + */ +export async function executeTbdCheck( + config: CheckConfig, + workingDir: string +): Promise { + const skipDirs = (config.skip_directories ?? DEFAULT_SKIP_DIRS).split('|'); + const skipFiles = (config.skip_files ?? DEFAULT_SKIP_FILES).split('|'); + const dirs = config.dirs ?? DEFAULT_DIRS; + + output.section('Checking for TBDs...'); + + const matches: TbdMatch[] = []; + + for (const dir of dirs) { + const dirPath = path.resolve(workingDir, dir); + if (!(await fs.pathExists(dirPath))) continue; + await scanDirectory(dirPath, workingDir, skipDirs, skipFiles, matches); + } + + if (matches.length === 0) { + output.success('No TBDs found!'); + output.log(''); + output.log(''); + output.success('Success! No TBDs found.'); + return { success: true, output: '' }; + } + + // Group by file + const grouped = new Map(); + for (const match of matches) { + const existing = grouped.get(match.file) ?? []; + existing.push(match); + grouped.set(match.file, existing); + } + + for (const [file, fileMatches] of grouped) { + const relPath = path.relative(workingDir, file); + output.log(chalk.cyan(relPath)); + for (const m of fileMatches) { + output.log(`${chalk.yellow(`${m.line}:`)} ${m.content.trim()}`); + } + output.log(''); + } + + output.log(''); + output.error('TBDs have been found!'); + + return { success: false, output: '' }; +} + +/** + * Recursively scans a directory for files containing TBD markers. + * + * @since TBD + * + * @param {string} dir - The directory to scan. + * @param {string} workingDir - The working directory used for relative path calculations. + * @param {string[]} skipDirs - Array of directory names to skip during scanning. + * @param {string[]} skipFiles - Array of file extensions/patterns to skip during scanning. + * @param {TbdMatch[]} matches - Array to accumulate TBD matches found during the scan. + * + * @returns {Promise} + */ +async function scanDirectory( + dir: string, + workingDir: string, + skipDirs: string[], + skipFiles: string[], + matches: TbdMatch[] +): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + if (skipDirs.some((skip) => entry.name === skip)) continue; + await scanDirectory(fullPath, workingDir, skipDirs, skipFiles, matches); + } else if (entry.isFile()) { + if (skipFiles.some((skip) => entry.name.endsWith(skip))) continue; + if (entry.name.startsWith('.pup-') || entry.name === '.puprc') continue; + await scanFile(fullPath, matches); + } + } +} + +/** + * Scans a single file for lines containing TBD markers. + * + * @since TBD + * + * @param {string} filePath - The full path to the file to scan. + * @param {TbdMatch[]} matches - Array to accumulate TBD matches found in the file. + * + * @returns {Promise} + */ +async function scanFile(filePath: string, matches: TbdMatch[]): Promise { + const contents = await fs.readFile(filePath, 'utf-8'); + const lines = contents.split('\n'); + + const tbdPatterns = [ + /\*\s*@(since|deprecated|version)\s.*tbd/i, + /_deprecated_\w\(.*['"]tbd['"]/i, + /['"]tbd['"]/i, + ]; + + for (let i = 0; i < lines.length; i++) { + if (tbdPatterns.some((pattern) => pattern.test(lines[i]))) { + matches.push({ + file: filePath, + line: i + 1, + content: lines[i], + }); + } + } +} diff --git a/tests/commands/check.test.ts b/tests/commands/check.test.ts index 89e2094..9bda18e 100644 --- a/tests/commands/check.test.ts +++ b/tests/commands/check.test.ts @@ -31,6 +31,32 @@ describe('check command', () => { }); }); +describe('check subcommands', () => { + afterEach(() => { + cleanupTempProjects(); + }); + + it('should register built-in check:tbd even when not in .puprc', async () => { + const projectDir = createTempProject(); + // Only version-conflict configured, no tbd + writePuprc(getPuprc({ checks: { 'version-conflict': {} } }), projectDir); + + const result = await runPup('check:tbd', { cwd: projectDir }); + expect(result.output).toContain('Checking for TBDs...'); + expect(result.output).not.toContain("unknown command 'check:tbd'"); + }); + + it('should run check:tbd without prefix', async () => { + const projectDir = createTempProject(); + writePuprc(getPuprc(), projectDir); + + const result = await runPup('check:tbd', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Checking for TBDs...'); + expect(result.output).not.toContain('[tbd]'); + }); +}); + describe('custom checks', () => { afterEach(() => { cleanupTempProjects(); diff --git a/tests/commands/checks/tbd.test.ts b/tests/commands/checks/tbd.test.ts new file mode 100644 index 0000000..5583954 --- /dev/null +++ b/tests/commands/checks/tbd.test.ts @@ -0,0 +1,331 @@ +import path from 'node:path'; +import fs from 'fs-extra'; +import { + runPup, + writePuprc, + getPuprc, + createTempProject, + cleanupTempProjects, +} from '../../helpers/setup.js'; + +describe('tbd check', () => { + afterEach(() => { + cleanupTempProjects(); + }); + + it('should run tbd but not version-conflict when only tbd is configured', async () => { + const projectDir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), projectDir); + + const result = await runPup('check', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('[tbd]'); + expect(result.output).not.toContain('[version-conflict]'); + }); + + it('should run successful tbd check', async () => { + const projectDir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), projectDir); + + const result = await runPup('check', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('[tbd] Checking for TBDs...'); + expect(result.output).toContain('[tbd] No TBDs found!'); + expect(result.output).toContain('[tbd] Success! No TBDs found.'); + }); + + it('should fail tbd check when tbds exist', async () => { + const tbdDir = createTempProject('fake-project-with-tbds'); + writePuprc(getPuprc({ checks: { tbd: {} } }), tbdDir); + + const result = await runPup('check', { cwd: tbdDir }); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain('[tbd] Checking for TBDs...'); + expect(result.output).toContain('[tbd] src/Plugin.php'); + expect(result.output).toContain('[tbd] src/Thing/AnotherFile.php'); + expect(result.output).toContain('TBDs have been found!'); + expect(result.output).toContain("tbd's fail_method in .puprc is set to"); + }); + + it('should use fail_method_dev when --dev is passed', async () => { + const tbdDir = createTempProject('fake-project-with-tbds'); + writePuprc(getPuprc({ checks: { tbd: {} } }), tbdDir); + + // Default fail_method is 'error', but fail_method_dev is 'warn' + const result = await runPup('check --dev', { cwd: tbdDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('[tbd]'); + expect(result.output).toContain('TBDs have been found!'); + }); + + it('should only scan files under --root subdirectory', async () => { + const projectDir = createTempProject('fake-project-with-tbds'); + + // Create a subdirectory with its own .puprc and a clean src/ + const subdir = path.join(projectDir, 'subproject'); + fs.mkdirSync(path.join(subdir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(subdir, 'src', 'Clean.php'), + ' { + const tbdDir = createTempProject('fake-project-with-tbds'); + const puprc = getPuprc(); + puprc.checks = { + tbd: { + fail_method: 'warn', + }, + }; + writePuprc(puprc, tbdDir); + + const result = await runPup('check', { cwd: tbdDir }); + // Should succeed since tbd is set to warn + expect(result.exitCode).toBe(0); + expect(result.output).toContain('[tbd]'); + expect(result.output).toContain('TBDs have been found!'); + }); + + describe('pattern matching', () => { + describe('@since/@deprecated/@version docblock tags', () => { + it('should match @since TBD', async () => { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + it('should match _deprecated_function with single-quoted TBD', async () => { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + it('should match single-quoted TBD', async () => { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + it('should not match bare TBD in a comment', async () => { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + ' { + const dir = createTempProject(); + writePuprc(getPuprc({ checks: { tbd: {} } }), dir); + fs.writeFileSync(path.join(dir, 'src', 'Test.php'), [ + '