diff --git a/packages/eslint-plugin-cli/config.js b/packages/eslint-plugin-cli/config.js index d164966cf68..29b6cd7b9f9 100644 --- a/packages/eslint-plugin-cli/config.js +++ b/packages/eslint-plugin-cli/config.js @@ -12,6 +12,7 @@ const rules = { 'command-conventional-flag-env': require('./rules/command-conventional-flag-env'), 'command-reserved-flags': require('./rules/command-reserved-flags'), 'command-json-output': require('./rules/command-json-output'), + 'command-help-verb-form': require('./rules/command-help-verb-form'), 'no-error-factory-functions': require('./rules/no-error-factory-functions'), 'no-process-cwd': require('./rules/no-process-cwd'), 'no-trailing-js-in-cli-kit-imports': require('./rules/no-trailing-js-in-cli-kit-imports'), @@ -160,6 +161,7 @@ const baseRules = { '@shopify/cli/command-conventional-flag-env': 'error', '@shopify/cli/command-reserved-flags': 'error', '@shopify/cli/command-json-output': 'error', + '@shopify/cli/command-help-verb-form': 'error', '@shopify/cli/no-error-factory-functions': 'error', '@shopify/cli/no-process-cwd': 'error', '@shopify/cli/no-trailing-js-in-cli-kit-imports': 'error', diff --git a/packages/eslint-plugin-cli/index.js b/packages/eslint-plugin-cli/index.js index e0440d0d368..7d4f00f2fa4 100644 --- a/packages/eslint-plugin-cli/index.js +++ b/packages/eslint-plugin-cli/index.js @@ -8,6 +8,7 @@ const plugin = { 'command-conventional-flag-env': require('./rules/command-conventional-flag-env'), 'command-reserved-flags': require('./rules/command-reserved-flags'), 'command-json-output': require('./rules/command-json-output'), + 'command-help-verb-form': require('./rules/command-help-verb-form'), 'no-error-factory-functions': require('./rules/no-error-factory-functions'), 'no-process-cwd': require('./rules/no-process-cwd'), 'no-trailing-js-in-cli-kit-imports': require('./rules/no-trailing-js-in-cli-kit-imports'), diff --git a/packages/eslint-plugin-cli/rules/command-help-verb-form.js b/packages/eslint-plugin-cli/rules/command-help-verb-form.js new file mode 100644 index 00000000000..4773df68831 --- /dev/null +++ b/packages/eslint-plugin-cli/rules/command-help-verb-form.js @@ -0,0 +1,134 @@ +const {isCommandPath} = require('./command-path') + +const imperativeVerbs = new Set([ + 'access', + 'activate', + 'add', + 'apply', + 'authenticate', + 'build', + 'cancel', + 'check', + 'clean', + 'clear', + 'clone', + 'compile', + 'configure', + 'connect', + 'copy', + 'create', + 'delete', + 'deploy', + 'disable', + 'discuss', + 'display', + 'download', + 'duplicate', + 'enable', + 'execute', + 'fetch', + 'generate', + 'get', + 'import', + 'initialize', + 'install', + 'link', + 'list', + 'log', + 'login', + 'logout', + 'migrate', + 'move', + 'open', + 'package', + 'populate', + 'preview', + 'print', + 'process', + 'profile', + 'provide', + 'publish', + 'pull', + 'push', + 'query', + 'record', + 'refresh', + 'release', + 'remove', + 'rename', + 'replay', + 'reset', + 'retrieve', + 'return', + 'review', + 'run', + 'schedule', + 'search', + 'serve', + 'set', + 'share', + 'show', + 'start', + 'stop', + 'stream', + 'surface', + 'switch', + 'sync', + 'test', + 'trigger', + 'uninstall', + 'unschedule', + 'update', + 'upgrade', + 'upload', + 'use', + 'validate', + 'view', + 'watch', +]) + +// These starts introduce conditional, product-name, or explanatory copy, not an imperative verb. +const nonVerbStarts = new Set(['if', 'shopify', 'the', 'this']) + +// Base forms such as "Pass" and "Focus" also end in s; their third-person forms end in "es". +const isThirdPerson = (word) => word.endsWith('s') && !/(ss|us)$/.test(word) + +module.exports = { + meta: { + type: 'suggestion', + docs: {description: 'check the first word of static command help for third-person verb form'}, + schema: [], + messages: { + imperative: + 'Start command help with a third-person verb, such as "Lists", not "{{verb}}". Reword non-verb copy or extend the non-verb allowlist.', + }, + }, + create(context) { + if (!isCommandPath(context.filename)) return {} + + return { + ExportDefaultDeclaration(node) { + if (node.declaration.type !== 'ClassDeclaration') return + for (const member of node.declaration.body.body) { + if (!member.static || member.computed || member.type !== 'PropertyDefinition') continue + if (!['summary', 'description', 'descriptionWithMarkdown'].includes(member.key.name)) continue + + const value = member.value + let text + if (value?.type === 'Literal') text = value.value + else if (value?.type === 'TemplateLiteral') { + text = value.quasis[0].value.cooked + if (value.expressions.length && !/^\s*\[?[a-z]+[\s\].,!?:;()]/i.test(text)) continue + } + if (typeof text !== 'string') continue + const verb = text.trimStart().match(/^\[?([a-z]+)(?=[\s\].,!?:;()]|$)/i)?.[1] + if (!verb) continue + const firstWord = verb.toLowerCase() + if (imperativeVerbs.has(firstWord) || (!isThirdPerson(firstWord) && !nonVerbStarts.has(firstWord))) { + context.report({node: value, messageId: 'imperative', data: {verb}}) + } + } + }, + } + }, +} diff --git a/packages/eslint-plugin-cli/rules/command-help-verb-form.test.js b/packages/eslint-plugin-cli/rules/command-help-verb-form.test.js new file mode 100644 index 00000000000..894e65e74fe --- /dev/null +++ b/packages/eslint-plugin-cli/rules/command-help-verb-form.test.js @@ -0,0 +1,188 @@ +const {RuleTester} = require('eslint') +const typescriptParser = require('@typescript-eslint/parser') + +const rule = require('./command-help-verb-form') + +const ruleTester = new RuleTester({ + languageOptions: {ecmaVersion: 2022, sourceType: 'module', parser: typescriptParser}, +}) +const filename = '/repo/packages/app/src/cli/commands/app/widgets/list.ts' +const addedVerbs = [ + 'Access', + 'Activate', + 'Apply', + 'Cancel', + 'Clear', + 'Compile', + 'Copy', + 'Discuss', + 'Migrate', + 'Move', + 'Process', + 'Profile', + 'Provide', + 'Query', + 'Refresh', + 'Remove', + 'Retrieve', + 'Return', + 'Schedule', + 'Search', + 'Stream', + 'Surface', + 'Sync', + 'Test', + 'Uninstall', + 'Unschedule', + 'Upload', + 'Use', + 'Watch', +] + +ruleTester.run('command-help-verb-form', rule, { + valid: [ + {name: 'third-person verb', filename, code: 'export default class List { static summary = "Lists widgets." }'}, + ...['Passes', 'Focuses', 'Addresses'].map((verb) => ({ + name: `third-person form ending in es: ${verb}`, + filename, + code: `export default class List { static summary = "${verb} widgets." }`, + })), + { + name: 'third-person authentication', + filename, + code: 'export default class List { static summary = "Authenticates against a store." }', + }, + { + name: 'derived description', + filename, + code: 'export default class List { static description = this.descriptionForHelp() }', + }, + { + name: 'third-person template', + filename, + code: `export default class List { static summary = \`Lists \${resource}.\` }`, + }, + {name: 'instance property', filename, code: 'export default class List { summary = "List widgets." }'}, + {name: 'computed property', filename, code: 'export default class List { static [summary] = "List widgets." }'}, + ...['If', 'Shopify', 'The', 'This'].map((word) => ({ + name: `non-verb start ${word}`, + filename, + code: `export default class List { static summary = "${word} widget information." }`, + })), + { + name: 'non-command file', + filename: '/repo/packages/app/src/cli/services/list.ts', + code: 'export default class List { static summary = "List widgets." }', + }, + { + name: 'known limit: separately exported class', + filename, + code: 'class List { static summary = "List widgets." }; export default List', + }, + { + name: 'known limit: quoted key', + filename, + code: 'export default class List { static "summary" = "List widgets." }', + }, + { + name: 'known limit: asserted text', + filename, + code: 'export default class List { static summary = "List widgets." as const }', + }, + { + name: 'known limit: incomplete first template word', + filename, + code: `export default class List { static summary = \`Cre\${suffix} widgets.\` }`, + }, + { + name: 'known limit: dynamic first word', + filename, + code: `export default class List { static summary = \`\${verb} widgets.\` }`, + }, + { + name: 'known limit: hyphenated word', + filename, + code: 'export default class List { static summary = "Re-run widgets." }', + }, + ], + invalid: [ + ...addedVerbs.map((verb) => ({ + name: `imperative ${verb}`, + filename, + code: `export default class List { static summary = "${verb} widgets." }`, + errors: [{messageId: 'imperative', data: {verb}}], + })), + { + name: 'known imperative', + filename, + code: 'export default class List { static summary = "List widgets." }', + errors: [{messageId: 'imperative', data: {verb: 'List'}}], + }, + ...['Pass', 'Press', 'Dismiss', 'Focus', 'Address'].map((verb) => ({ + name: `imperative ending in ss or us: ${verb}`, + filename, + code: `export default class List { static summary = "${verb} widgets." }`, + errors: [{messageId: 'imperative', data: {verb}}], + })), + { + name: 'unknown imperative caught by suffix check', + filename, + code: 'export default class List { static summary = "Frobnicate widgets." }', + errors: [{messageId: 'imperative', data: {verb: 'Frobnicate'}}], + }, + { + name: 'constant template', + filename, + code: 'export default class List { static description = `Create a widget.` }', + errors: [{messageId: 'imperative', data: {verb: 'Create'}}], + }, + { + name: 'leading newline in markdown', + filename, + code: ` + export default class List { + static descriptionWithMarkdown = \` + Run a widget. More detail. + \` + } + `, + errors: [{messageId: 'imperative', data: {verb: 'Run'}}], + }, + { + name: 'Windows command path', + filename: String.raw`C:\repo\packages\app\src\cli\commands\widgets\list.ts`, + code: 'export default class List { static summary = "Show widgets." }', + errors: [{messageId: 'imperative'}], + }, + { + name: 'src commands path', + filename: '/repo/packages/plugin-x/src/commands/list.ts', + code: 'export default class List { static summary = "Show widgets." }', + errors: [{messageId: 'imperative'}], + }, + { + name: 'typed optional property', + filename, + code: 'export default class List { static summary?: string | undefined = "Create widgets." }', + errors: [{messageId: 'imperative'}], + }, + { + name: 'hidden command', + filename, + code: 'export default class List { static hidden = true; static summary = "Check widgets." }', + errors: [{messageId: 'imperative'}], + }, + { + name: 'complete first template word', + filename, + code: `export default class List { static summary = \`Create \${resource}.\` }`, + errors: [{messageId: 'imperative'}], + }, + { + name: 'leading markdown link', + filename, + code: 'export default class List { static summary = "[Build](https://example.com) widgets." }', + errors: [{messageId: 'imperative'}], + }, + ], +}) diff --git a/packages/eslint-plugin-cli/rules/command-json-output.js b/packages/eslint-plugin-cli/rules/command-json-output.js index 4d41adab57a..ae27fdc78d7 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.js @@ -1,4 +1,5 @@ const {commandExceptions} = require('./json-output-command-exceptions') +const {isCommandPath} = require('./command-path') module.exports = { meta: { @@ -49,10 +50,6 @@ module.exports = { }, } -function isCommandPath(commandPath) { - return /\/src\/(?:cli\/)?commands\//.test(commandPath) -} - function repositoryPath(filename) { const normalizedFilename = filename.replaceAll('\\', '/') const packagesDirectory = normalizedFilename.lastIndexOf('/packages/') diff --git a/packages/eslint-plugin-cli/rules/command-path.js b/packages/eslint-plugin-cli/rules/command-path.js new file mode 100644 index 00000000000..f380213d697 --- /dev/null +++ b/packages/eslint-plugin-cli/rules/command-path.js @@ -0,0 +1,5 @@ +function isCommandPath(filename) { + return /\/src\/(?:cli\/)?commands\//.test(filename.replaceAll('\\', '/')) +} + +module.exports = {isCommandPath}