feat: use levenshtein distance for summary matches name validation rule

This commit is contained in:
tdgao
2026-09-01 15:38:05 -06:00
parent 5dd95fedde
commit b889de7799
3 changed files with 61 additions and 9 deletions
@@ -435,7 +435,7 @@
"defaultMessage": "Visit links settings"
},
"project.text-validation.summary-matches-title": {
"defaultMessage": "A project summary cannot be the same as it's title."
"defaultMessage": "A project summary cannot be the same as or too similar to its title."
},
"project.text-validation.summary-too-short": {
"defaultMessage": "Your summary is too short. Add a sentence or two which describes your project."
@@ -66,7 +66,7 @@ const messages = defineMessages({
},
matchesName: {
id: 'project.text-validation.summary-matches-title',
defaultMessage: "A project summary cannot be the same as it's title.",
defaultMessage: 'A project summary cannot be the same as or too similar to its title.',
},
tooShort: {
id: 'project.text-validation.summary-too-short',
@@ -89,6 +89,7 @@ const messages = defineMessages({
})
export const MIN_SUMMARY_CHARS = 25
export const MAX_SUMMARY_NAME_SIMILARITY = 0.8
export interface ProjectSummaryValidationInput {
summary: string | null | undefined
@@ -105,11 +106,42 @@ function findProjectSummaryLinkOrIp(summary: string): string | null {
return summaryLinkify.match(summary)?.[0].raw ?? null
}
export function projectSummaryMatchesName(summary: string, name: string) {
const normalizedSummary = normalizeProjectFieldText(summary).replace(/\s+/g, '')
const normalizedName = normalizeProjectFieldText(name).replace(/\s+/g, '')
function getLevenshteinDistance(left: string[], right: string[]) {
if (left.length > right.length) return getLevenshteinDistance(right, left)
return normalizedSummary.length > 0 && normalizedSummary === normalizedName
let previousRow = Array.from({ length: left.length + 1 }, (_, index) => index)
for (let rightIndex = 0; rightIndex < right.length; rightIndex++) {
const currentRow = [rightIndex + 1]
for (let leftIndex = 0; leftIndex < left.length; leftIndex++) {
currentRow.push(
Math.min(
currentRow[leftIndex] + 1,
previousRow[leftIndex + 1] + 1,
previousRow[leftIndex] + (left[leftIndex] === right[rightIndex] ? 0 : 1),
),
)
}
previousRow = currentRow
}
return previousRow[left.length]
}
function normalizeProjectFieldTextForSimilarity(value: string) {
return Array.from(normalizeProjectFieldText(value).toLocaleLowerCase('en-US').replace(/\s+/g, ''))
}
export function getProjectSummaryNameSimilarity(summary: string, name: string) {
const normalizedSummary = normalizeProjectFieldTextForSimilarity(summary)
const normalizedName = normalizeProjectFieldTextForSimilarity(name)
const longestLength = Math.max(normalizedSummary.length, normalizedName.length)
if (longestLength === 0) return 0
return 1 - getLevenshteinDistance(normalizedSummary, normalizedName) / longestLength
}
export function hasProjectSummaryFormatting(summary: string) {
@@ -172,7 +204,7 @@ export const projectSummaryValidationRules = {
!summary ||
findProjectSummaryLinkOrIp(summary) !== null ||
!name ||
!projectSummaryMatchesName(summary, name),
getProjectSummaryNameSimilarity(summary, name) < MAX_SUMMARY_NAME_SIMILARITY,
}),
presentation: {
message: messages.matchesName,
@@ -21,8 +21,8 @@ import { validateProjectDisclosures } from './rules/disclosures.ts'
import { validateProjectGalleryDescription, validateProjectGalleryName } from './rules/gallery.ts'
import { projectNameValidationRules, validateProjectNameField } from './rules/name.ts'
import {
getProjectSummaryNameSimilarity,
hasProjectSummaryFormatting,
projectSummaryMatchesName,
validateProjectSummary,
} from './rules/summary.ts'
import { projectVersionValidationRules } from './rules/versions.ts'
@@ -166,7 +166,7 @@ test('requires known environments for mods and modpacks', () => {
})
test('validates summary content from one rule set', () => {
assert.equal(projectSummaryMatchesName(' Caf\u00e9 ', 'Cafe\u0301'), true)
assert.equal(getProjectSummaryNameSimilarity(' Caf\u00e9 ', 'Cafe\u0301'), 1)
assert.deepEqual(
validateProjectSummary({ summary: '# Short summary', name: 'Project title' }).map(
({ code, severity }) => ({ code, severity }),
@@ -185,6 +185,26 @@ test('validates summary content from one rule set', () => {
)
})
test('rejects project summaries that are too similar to the project name', () => {
for (const summary of ['My Project', 'myproject', 'My Projec', 'My Projects']) {
assert.equal(
validateProjectSummary({ summary, name: 'My Project' }).some(
({ code }) => code === 'project-summary-matches-title',
),
true,
summary,
)
}
assert.equal(
validateProjectSummary({
summary: 'A detailed summary of what My Project provides',
name: 'My Project',
}).some(({ code }) => code === 'project-summary-matches-title'),
false,
)
})
test('warns when a project summary is mostly non-English', () => {
assert.deepEqual(
validateProjectSummary({