From 0e866fde8cd5c26d68bede46e31160d4e4e14bf2 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Sat, 10 Oct 2026 21:21:27 -0400 Subject: [PATCH 1/3] Add off-by-default command help verb form rule --- packages/eslint-plugin-cli/config.js | 1 + packages/eslint-plugin-cli/index.js | 1 + .../rules/command-help-verb-form.js | 131 +++++++++++++ .../rules/command-help-verb-form.test.js | 177 ++++++++++++++++++ .../rules/command-json-output.js | 5 +- .../eslint-plugin-cli/rules/command-path.js | 5 + 6 files changed, 316 insertions(+), 4 deletions(-) create mode 100644 packages/eslint-plugin-cli/rules/command-help-verb-form.js create mode 100644 packages/eslint-plugin-cli/rules/command-help-verb-form.test.js create mode 100644 packages/eslint-plugin-cli/rules/command-path.js diff --git a/packages/eslint-plugin-cli/config.js b/packages/eslint-plugin-cli/config.js index d164966cf68..f9e3a62cd0c 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'), 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..f458e708b83 --- /dev/null +++ b/packages/eslint-plugin-cli/rules/command-help-verb-form.js @@ -0,0 +1,131 @@ +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']) + +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) || (!firstWord.endsWith('s') && !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..64ef986f60b --- /dev/null +++ b/packages/eslint-plugin-cli/rules/command-help-verb-form.test.js @@ -0,0 +1,177 @@ +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." }'}, + { + 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'}}], + }, + { + 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} From e0752c1cd6fd6007a2c2114bce593247abea3f1f Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Sat, 10 Oct 2026 21:32:12 -0400 Subject: [PATCH 2/3] Treat base forms ending in ss or us as imperative in help verb rule --- .../eslint-plugin-cli/rules/command-help-verb-form.js | 5 ++++- .../rules/command-help-verb-form.test.js | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin-cli/rules/command-help-verb-form.js b/packages/eslint-plugin-cli/rules/command-help-verb-form.js index f458e708b83..4773df68831 100644 --- a/packages/eslint-plugin-cli/rules/command-help-verb-form.js +++ b/packages/eslint-plugin-cli/rules/command-help-verb-form.js @@ -90,6 +90,9 @@ const imperativeVerbs = new Set([ // 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', @@ -121,7 +124,7 @@ module.exports = { const verb = text.trimStart().match(/^\[?([a-z]+)(?=[\s\].,!?:;()]|$)/i)?.[1] if (!verb) continue const firstWord = verb.toLowerCase() - if (imperativeVerbs.has(firstWord) || (!firstWord.endsWith('s') && !nonVerbStarts.has(firstWord))) { + 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 index 64ef986f60b..894e65e74fe 100644 --- a/packages/eslint-plugin-cli/rules/command-help-verb-form.test.js +++ b/packages/eslint-plugin-cli/rules/command-help-verb-form.test.js @@ -42,6 +42,11 @@ const addedVerbs = [ 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, @@ -113,6 +118,12 @@ ruleTester.run('command-help-verb-form', rule, { 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, From 560518932d615926dee59f6323a2f3b1fcc20444 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Sun, 11 Oct 2026 10:22:35 -0400 Subject: [PATCH 3/3] Enable command help verb form lint rule --- packages/eslint-plugin-cli/config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/eslint-plugin-cli/config.js b/packages/eslint-plugin-cli/config.js index f9e3a62cd0c..29b6cd7b9f9 100644 --- a/packages/eslint-plugin-cli/config.js +++ b/packages/eslint-plugin-cli/config.js @@ -161,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',