Skip to content

Commit 5f60d48

Browse files
authored
Add content linter rule for outdated release phase terminology (GHD046) (#56093)
1 parent bd35bcb commit 5f60d48

File tree

5 files changed

+320
-36
lines changed

5 files changed

+320
-36
lines changed

data/reusables/contributing/content-linter-rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
| GHD042 | liquid-tag-whitespace | Liquid tags should start and end with one whitespace. Liquid tag arguments should be separated by only one whitespace. | error | liquid, format |
7070
| GHD043 | link-quotation | Internal link titles must not be surrounded by quotations | error | links, url |
7171
| GHD044 | octicon-aria-labels | Octicons should always have an aria-label attribute even if aria-hidden. | warning | accessibility, octicons |
72+
| GHD046 | outdated-release-phase-terminology | Outdated release phase terminology should be replaced with current GitHub terminology | warning | terminology, consistency, release-phases |
7273
| GHD048 | british-english-quotes | Periods and commas should be placed inside quotation marks (American English style) | warning | punctuation, quotes, style, consistency |
7374
| GHD050 | multiple-emphasis-patterns | Do not use more than one emphasis/strong, italics, or uppercase for a string | warning | formatting, emphasis, style |
7475
| GHD049 | note-warning-formatting | Note and warning tags should be formatted according to style guide | warning | formatting, callouts, notes, warnings, style |

src/content-linter/lib/linting-rules/index.js

Lines changed: 50 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,55 @@
11
import searchReplace from 'markdownlint-rule-search-replace'
22
import markdownlintGitHub from '@github/markdownlint-github'
33

4-
import { codeFenceLineLength } from './code-fence-line-length'
5-
import { imageAltTextEndPunctuation } from './image-alt-text-end-punctuation'
6-
import { imageFileKebabCase } from './image-file-kebab-case'
7-
import { incorrectAltTextLength } from './image-alt-text-length'
8-
import { internalLinksNoLang } from './internal-links-no-lang'
9-
import { internalLinksSlash } from './internal-links-slash'
10-
import { imageAltTextExcludeStartWords } from './image-alt-text-exclude-start-words'
11-
import { listFirstWordCapitalization } from './list-first-word-capitalization'
12-
import { linkPunctuation } from './link-punctuation'
13-
import { earlyAccessReferences, frontmatterEarlyAccessReferences } from './early-access-references'
14-
import { frontmatterHiddenDocs } from './frontmatter-hidden-docs'
15-
import { frontmatterVideoTranscripts } from './frontmatter-video-transcripts'
16-
import { yamlScheduledJobs } from './yaml-scheduled-jobs'
17-
import { internalLinksOldVersion } from './internal-links-old-version'
18-
import { hardcodedDataVariable } from './hardcoded-data-variable'
19-
import { githubOwnedActionReferences } from './github-owned-action-references'
20-
import { liquidQuotedConditionalArg } from './liquid-quoted-conditional-arg'
21-
import { liquidDataReferencesDefined, liquidDataTagFormat } from './liquid-data-tags'
22-
import { frontmatterSchema } from './frontmatter-schema'
23-
import { codeAnnotations } from './code-annotations'
24-
import { codeAnnotationCommentSpacing } from './code-annotation-comment-spacing'
25-
import { frontmatterLiquidSyntax, liquidSyntax } from './liquid-syntax'
26-
import { liquidIfTags, liquidIfVersionTags } from './liquid-versioning'
27-
import { raiReusableUsage } from './rai-reusable-usage'
28-
import { imageNoGif } from './image-no-gif'
29-
import { expiredContent, expiringSoon } from './expired-content'
30-
import { tableLiquidVersioning } from './table-liquid-versioning'
31-
import { tableColumnIntegrity } from './table-column-integrity'
32-
import { thirdPartyActionPinning } from './third-party-action-pinning'
33-
import { liquidTagWhitespace } from './liquid-tag-whitespace'
34-
import { linkQuotation } from './link-quotation'
35-
import { octiconAriaLabels } from './octicon-aria-labels'
36-
import { liquidIfversionVersions } from './liquid-ifversion-versions'
37-
import { britishEnglishQuotes } from './british-english-quotes'
38-
import { multipleEmphasisPatterns } from './multiple-emphasis-patterns'
39-
import { noteWarningFormatting } from './note-warning-formatting'
4+
import { codeFenceLineLength } from '@/content-linter/lib/linting-rules/code-fence-line-length'
5+
import { imageAltTextEndPunctuation } from '@/content-linter/lib/linting-rules/image-alt-text-end-punctuation'
6+
import { imageFileKebabCase } from '@/content-linter/lib/linting-rules/image-file-kebab-case'
7+
import { incorrectAltTextLength } from '@/content-linter/lib/linting-rules/image-alt-text-length'
8+
import { internalLinksNoLang } from '@/content-linter/lib/linting-rules/internal-links-no-lang'
9+
import { internalLinksSlash } from '@/content-linter/lib/linting-rules/internal-links-slash'
10+
import { imageAltTextExcludeStartWords } from '@/content-linter/lib/linting-rules/image-alt-text-exclude-start-words'
11+
import { listFirstWordCapitalization } from '@/content-linter/lib/linting-rules/list-first-word-capitalization'
12+
import { linkPunctuation } from '@/content-linter/lib/linting-rules/link-punctuation'
13+
import {
14+
earlyAccessReferences,
15+
frontmatterEarlyAccessReferences,
16+
} from '@/content-linter/lib/linting-rules/early-access-references'
17+
import { frontmatterHiddenDocs } from '@/content-linter/lib/linting-rules/frontmatter-hidden-docs'
18+
import { frontmatterVideoTranscripts } from '@/content-linter/lib/linting-rules/frontmatter-video-transcripts'
19+
import { yamlScheduledJobs } from '@/content-linter/lib/linting-rules/yaml-scheduled-jobs'
20+
import { internalLinksOldVersion } from '@/content-linter/lib/linting-rules/internal-links-old-version'
21+
import { hardcodedDataVariable } from '@/content-linter/lib/linting-rules/hardcoded-data-variable'
22+
import { githubOwnedActionReferences } from '@/content-linter/lib/linting-rules/github-owned-action-references'
23+
import { liquidQuotedConditionalArg } from '@/content-linter/lib/linting-rules/liquid-quoted-conditional-arg'
24+
import {
25+
liquidDataReferencesDefined,
26+
liquidDataTagFormat,
27+
} from '@/content-linter/lib/linting-rules/liquid-data-tags'
28+
import { frontmatterSchema } from '@/content-linter/lib/linting-rules/frontmatter-schema'
29+
import { codeAnnotations } from '@/content-linter/lib/linting-rules/code-annotations'
30+
import { codeAnnotationCommentSpacing } from '@/content-linter/lib/linting-rules/code-annotation-comment-spacing'
31+
import {
32+
frontmatterLiquidSyntax,
33+
liquidSyntax,
34+
} from '@/content-linter/lib/linting-rules/liquid-syntax'
35+
import {
36+
liquidIfTags,
37+
liquidIfVersionTags,
38+
} from '@/content-linter/lib/linting-rules/liquid-versioning'
39+
import { raiReusableUsage } from '@/content-linter/lib/linting-rules/rai-reusable-usage'
40+
import { imageNoGif } from '@/content-linter/lib/linting-rules/image-no-gif'
41+
import { expiredContent, expiringSoon } from '@/content-linter/lib/linting-rules/expired-content'
42+
import { tableLiquidVersioning } from '@/content-linter/lib/linting-rules/table-liquid-versioning'
43+
import { tableColumnIntegrity } from '@/content-linter/lib/linting-rules/table-column-integrity'
44+
import { thirdPartyActionPinning } from '@/content-linter/lib/linting-rules/third-party-action-pinning'
45+
import { liquidTagWhitespace } from '@/content-linter/lib/linting-rules/liquid-tag-whitespace'
46+
import { linkQuotation } from '@/content-linter/lib/linting-rules/link-quotation'
47+
import { octiconAriaLabels } from '@/content-linter/lib/linting-rules/octicon-aria-labels'
48+
import { liquidIfversionVersions } from '@/content-linter/lib/linting-rules/liquid-ifversion-versions'
49+
import { outdatedReleasePhaseTerminology } from '@/content-linter/lib/linting-rules/outdated-release-phase-terminology'
50+
import { britishEnglishQuotes } from '@/content-linter/lib/linting-rules/british-english-quotes'
51+
import { multipleEmphasisPatterns } from '@/content-linter/lib/linting-rules/multiple-emphasis-patterns'
52+
import { noteWarningFormatting } from '@/content-linter/lib/linting-rules/note-warning-formatting'
4053

4154
const noDefaultAltText = markdownlintGitHub.find((elem) =>
4255
elem.names.includes('no-default-alt-text'),
@@ -88,6 +101,7 @@ export const gitHubDocsMarkdownlint = {
88101
liquidTagWhitespace,
89102
linkQuotation,
90103
octiconAriaLabels,
104+
outdatedReleasePhaseTerminology,
91105
britishEnglishQuotes,
92106
multipleEmphasisPatterns,
93107
noteWarningFormatting,
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { addError, ellipsify } from 'markdownlint-rule-helpers'
2+
3+
import { getRange } from '@/content-linter/lib/helpers/utils'
4+
import frontmatter from '@/frame/lib/read-frontmatter'
5+
6+
// Mapping of outdated terms to their new replacements
7+
// Order matters - longer phrases must come first to avoid partial matches
8+
const TERMINOLOGY_REPLACEMENTS = [
9+
// Beta variations → public preview (longer phrases first)
10+
['limited public beta', 'public preview'],
11+
['public beta', 'public preview'],
12+
['private beta', 'private preview'],
13+
['beta', 'public preview'],
14+
15+
// Alpha → private preview
16+
['alpha', 'private preview'],
17+
18+
// Deprecated variations → closing down
19+
['deprecation', 'closing down'],
20+
['deprecated', 'closing down'],
21+
22+
// Sunset → retired
23+
['sunset', 'retired'],
24+
]
25+
26+
// Precompile RegExp objects for better performance
27+
const COMPILED_REGEXES = TERMINOLOGY_REPLACEMENTS.map(([outdatedTerm, replacement]) => ({
28+
regex: new RegExp(`(?<!\\w|-|_)${outdatedTerm.replace(/\s+/g, '\\s+')}(?!\\w|-|_)`, 'gi'),
29+
outdatedTerm,
30+
replacement,
31+
}))
32+
33+
/**
34+
* Find all non-overlapping matches of outdated terminology in a line
35+
* @param {string} line - The line of text to search
36+
* @returns {Array} Array of match objects with start, end, text, replacement, and outdatedTerm
37+
*/
38+
function findOutdatedTerminologyMatches(line) {
39+
const foundMatches = []
40+
41+
// Check each outdated term (in order - longest first)
42+
for (const { regex, outdatedTerm, replacement } of COMPILED_REGEXES) {
43+
// Reset regex state for each line
44+
regex.lastIndex = 0
45+
let match
46+
47+
while ((match = regex.exec(line)) !== null) {
48+
// Check if this match overlaps with any existing matches
49+
const overlaps = foundMatches.some(
50+
(existing) =>
51+
(match.index >= existing.start && match.index < existing.end) ||
52+
(match.index + match[0].length > existing.start &&
53+
match.index + match[0].length <= existing.end) ||
54+
(match.index <= existing.start && match.index + match[0].length >= existing.end),
55+
)
56+
57+
if (!overlaps) {
58+
foundMatches.push({
59+
start: match.index,
60+
end: match.index + match[0].length,
61+
text: match[0],
62+
replacement: replacement,
63+
outdatedTerm: outdatedTerm,
64+
})
65+
}
66+
}
67+
}
68+
69+
// Sort matches by position for consistent ordering
70+
return foundMatches.sort((a, b) => a.start - b.start)
71+
}
72+
73+
export const outdatedReleasePhaseTerminology = {
74+
names: ['GHD046', 'outdated-release-phase-terminology'],
75+
description:
76+
'Outdated release phase terminology should be replaced with current GitHub terminology',
77+
tags: ['terminology', 'consistency', 'release-phases'],
78+
severity: 'error',
79+
function: (params, onError) => {
80+
// Skip autogenerated files
81+
const frontmatterString = params.frontMatterLines.join('\n')
82+
const fm = frontmatter(frontmatterString).data
83+
if (fm && fm.autogenerated) return
84+
85+
// Check all lines for outdated terminology
86+
for (let i = 0; i < params.lines.length; i++) {
87+
const line = params.lines[i]
88+
const lineNumber = i + 1
89+
90+
// Find all matches on this line
91+
const foundMatches = findOutdatedTerminologyMatches(line)
92+
93+
// Report all found matches
94+
for (const matchInfo of foundMatches) {
95+
const range = getRange(line, matchInfo.text)
96+
const errorMessage = `Replace outdated terminology "${matchInfo.text}" with "${matchInfo.replacement}"`
97+
98+
// Provide a fix suggestion
99+
const fixInfo = {
100+
editColumn: matchInfo.start + 1,
101+
deleteCount: matchInfo.text.length,
102+
insertText: matchInfo.replacement,
103+
}
104+
105+
addError(onError, lineNumber, errorMessage, ellipsify(line), range, fixInfo)
106+
}
107+
}
108+
},
109+
}

src/content-linter/style/github-docs.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,12 @@ const githubDocsConfig = {
206206
'partial-markdown-files': true,
207207
'yml-files': true,
208208
},
209+
'outdated-release-phase-terminology': {
210+
// GHD046
211+
severity: 'warning',
212+
'partial-markdown-files': true,
213+
'yml-files': true,
214+
},
209215
'table-column-integrity': {
210216
// GHD047
211217
severity: 'warning',
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { runRule } from '@/content-linter/lib/init-test'
4+
import { outdatedReleasePhaseTerminology } from '@/content-linter/lib/linting-rules/outdated-release-phase-terminology'
5+
6+
describe(outdatedReleasePhaseTerminology.names.join(' - '), () => {
7+
test('Using outdated beta terminology causes error', async () => {
8+
const markdown = [
9+
'This feature is in beta.',
10+
'The public beta is available now.',
11+
'We are running a limited public beta.',
12+
].join('\n')
13+
const result = await runRule(outdatedReleasePhaseTerminology, { strings: { markdown } })
14+
const errors = result.markdown
15+
expect(errors.length).toBe(3)
16+
expect(errors[0].lineNumber).toBe(1)
17+
expect(errors[0].errorDetail).toContain(
18+
'Replace outdated terminology "beta" with "public preview"',
19+
)
20+
expect(errors[1].lineNumber).toBe(2)
21+
expect(errors[1].errorDetail).toContain(
22+
'Replace outdated terminology "public beta" with "public preview"',
23+
)
24+
expect(errors[2].lineNumber).toBe(3)
25+
expect(errors[2].errorDetail).toContain(
26+
'Replace outdated terminology "limited public beta" with "public preview"',
27+
)
28+
})
29+
30+
test('Using outdated private beta and alpha terminology causes error', async () => {
31+
const markdown = ['The private beta starts next month.', 'This alpha version has bugs.'].join(
32+
'\n',
33+
)
34+
const result = await runRule(outdatedReleasePhaseTerminology, { strings: { markdown } })
35+
const errors = result.markdown
36+
expect(errors.length).toBe(2)
37+
expect(errors[0].lineNumber).toBe(1)
38+
expect(errors[0].errorDetail).toContain(
39+
'Replace outdated terminology "private beta" with "private preview"',
40+
)
41+
expect(errors[1].lineNumber).toBe(2)
42+
expect(errors[1].errorDetail).toContain(
43+
'Replace outdated terminology "alpha" with "private preview"',
44+
)
45+
})
46+
47+
test('Using outdated deprecated terminology causes error', async () => {
48+
const markdown = ['This feature is deprecated.', 'The deprecation timeline is available.'].join(
49+
'\n',
50+
)
51+
const result = await runRule(outdatedReleasePhaseTerminology, { strings: { markdown } })
52+
const errors = result.markdown
53+
expect(errors.length).toBe(2)
54+
expect(errors[0].lineNumber).toBe(1)
55+
expect(errors[0].errorDetail).toContain(
56+
'Replace outdated terminology "deprecated" with "closing down"',
57+
)
58+
expect(errors[1].lineNumber).toBe(2)
59+
expect(errors[1].errorDetail).toContain(
60+
'Replace outdated terminology "deprecation" with "closing down"',
61+
)
62+
})
63+
64+
test('Using outdated sunset terminology causes error', async () => {
65+
const markdown = ['This API will sunset in 2024.'].join('\n')
66+
const result = await runRule(outdatedReleasePhaseTerminology, { strings: { markdown } })
67+
const errors = result.markdown
68+
expect(errors.length).toBe(1)
69+
expect(errors[0].lineNumber).toBe(1)
70+
expect(errors[0].errorDetail).toContain('Replace outdated terminology "sunset" with "retired"')
71+
})
72+
73+
test('Case insensitive matching works', async () => {
74+
const markdown = [
75+
'This BETA feature is great.',
76+
'The Alpha version is ready.',
77+
'This is DEPRECATED.',
78+
'We will SUNSET this feature.',
79+
].join('\n')
80+
const result = await runRule(outdatedReleasePhaseTerminology, { strings: { markdown } })
81+
const errors = result.markdown
82+
expect(errors.length).toBe(4)
83+
expect(errors[0].errorDetail).toContain(
84+
'Replace outdated terminology "BETA" with "public preview"',
85+
)
86+
expect(errors[1].errorDetail).toContain(
87+
'Replace outdated terminology "Alpha" with "private preview"',
88+
)
89+
expect(errors[2].errorDetail).toContain(
90+
'Replace outdated terminology "DEPRECATED" with "closing down"',
91+
)
92+
expect(errors[3].errorDetail).toContain('Replace outdated terminology "SUNSET" with "retired"')
93+
})
94+
95+
test('Word boundaries prevent false positives in compound words', async () => {
96+
const markdown = [
97+
'The alphabet contains letters.',
98+
'We use betaflight software.',
99+
'The deprecated-api endpoint is different.',
100+
].join('\n')
101+
const result = await runRule(outdatedReleasePhaseTerminology, { strings: { markdown } })
102+
const errors = result.markdown
103+
expect(errors.length).toBe(0)
104+
})
105+
106+
test('Context-sensitive terms are flagged (human review needed)', async () => {
107+
const markdown = ['A beautiful sunset view.', 'The API will sunset next year.'].join('\n')
108+
const result = await runRule(outdatedReleasePhaseTerminology, { strings: { markdown } })
109+
const errors = result.markdown
110+
expect(errors.length).toBe(2)
111+
expect(errors[0].errorDetail).toContain('Replace outdated terminology "sunset" with "retired"')
112+
expect(errors[1].errorDetail).toContain('Replace outdated terminology "sunset" with "retired"')
113+
})
114+
115+
test('Multiple occurrences on same line are all caught', async () => {
116+
const markdown = ['This beta feature replaces the deprecated alpha version.'].join('\n')
117+
const result = await runRule(outdatedReleasePhaseTerminology, { strings: { markdown } })
118+
const errors = result.markdown
119+
expect(errors.length).toBe(3)
120+
expect(errors[0].errorDetail).toContain(
121+
'Replace outdated terminology "beta" with "public preview"',
122+
)
123+
expect(errors[1].errorDetail).toContain(
124+
'Replace outdated terminology "deprecated" with "closing down"',
125+
)
126+
expect(errors[2].errorDetail).toContain(
127+
'Replace outdated terminology "alpha" with "private preview"',
128+
)
129+
})
130+
131+
test('New terminology does not cause errors', async () => {
132+
const markdown = [
133+
'This feature is in public preview.',
134+
'The private preview is available now.',
135+
'This feature is closing down.',
136+
'The API has been retired.',
137+
].join('\n')
138+
const result = await runRule(outdatedReleasePhaseTerminology, { strings: { markdown } })
139+
const errors = result.markdown
140+
expect(errors.length).toBe(0)
141+
})
142+
143+
test('Autogenerated files are skipped', async () => {
144+
const frontmatter = ['---', 'title: Test', 'autogenerated: rest', '---'].join('\n')
145+
const markdown = ['This feature is in beta.'].join('\n')
146+
const result = await runRule(outdatedReleasePhaseTerminology, {
147+
strings: {
148+
markdown: frontmatter + '\n' + markdown,
149+
},
150+
})
151+
const errors = result.markdown
152+
expect(errors.length).toBe(0)
153+
})
154+
})

0 commit comments

Comments
 (0)