Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/eslint-plugin-cli/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin-cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
134 changes: 134 additions & 0 deletions packages/eslint-plugin-cli/rules/command-help-verb-form.js
Original file line number Diff line number Diff line change
@@ -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}})
}
}
},
}
},
}
188 changes: 188 additions & 0 deletions packages/eslint-plugin-cli/rules/command-help-verb-form.test.js
Original file line number Diff line number Diff line change
@@ -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'}],
},
],
})
5 changes: 1 addition & 4 deletions packages/eslint-plugin-cli/rules/command-json-output.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const {commandExceptions} = require('./json-output-command-exceptions')
const {isCommandPath} = require('./command-path')

module.exports = {
meta: {
Expand Down Expand Up @@ -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/')
Expand Down
5 changes: 5 additions & 0 deletions packages/eslint-plugin-cli/rules/command-path.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
function isCommandPath(filename) {
return /\/src\/(?:cli\/)?commands\//.test(filename.replaceAll('\\', '/'))
}

module.exports = {isCommandPath}
Loading