mirror of
https://github.com/modrinth/code.git
synced 2026-09-05 06:19:11 +00:00
feat: use levenshtein distance for summary matches name validation rule
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user