From 1116a95926727a80bd53dad23aa19af7eb23b23e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 1 Aug 2026 05:39:45 +0800 Subject: [PATCH] fix(entities): resolveEntitySlug fallback keeps the path separator (#3447) (#3567) Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com> --- src/core/entities/resolve.ts | 17 +++++++- test/entity-resolve-slug-fallback.test.ts | 53 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 test/entity-resolve-slug-fallback.test.ts diff --git a/src/core/entities/resolve.ts b/src/core/entities/resolve.ts index 5aed381f5..7305b21b8 100644 --- a/src/core/entities/resolve.ts +++ b/src/core/entities/resolve.ts @@ -74,6 +74,21 @@ export async function resolveEntitySlug( } // 4. Fallback: deterministic slugify. + return fallbackSlugify(trimmed); +} + +/** + * #3447 — shared fallback for both resolvers. slugify()'s `[^a-z0-9]+ → '-'` + * rule rewrites the path separator, so running slug-shaped input through it + * corrupts a well-formed slug (`people/alice-example` → `people-alice-example`) + * into one no page can ever have — and the flattened slug never resolves, so + * every re-extraction re-mints it. Path-shaped input is slugified PER SEGMENT + * (identity for already-well-formed slugs); display names keep plain slugify. + */ +function fallbackSlugify(trimmed: string): string { + if (trimmed.includes('/')) { + return trimmed.split('/').map(slugify).filter(Boolean).join('/'); + } return slugify(trimmed); } @@ -141,7 +156,7 @@ export async function resolveEntitySlugWithSource( if (fuzzy) return { slug: fuzzy, source: 'fuzzy_match' }; } - return { slug: slugify(trimmed), source: 'fallback_slugify' }; + return { slug: fallbackSlugify(trimmed), source: 'fallback_slugify' }; } /** diff --git a/test/entity-resolve-slug-fallback.test.ts b/test/entity-resolve-slug-fallback.test.ts new file mode 100644 index 000000000..004ae7ceb --- /dev/null +++ b/test/entity-resolve-slug-fallback.test.ts @@ -0,0 +1,53 @@ +// #3447 — resolveEntitySlug's slugify fallback must not corrupt slug-shaped +// input. slugify()'s `[^a-z0-9]+ → '-'` rule rewrites the path separator +// (`people/alice-example` → `people-alice-example`), minting an entity_slug +// no page can ever have. The corruption is self-perpetuating: the flattened +// slug never matches a page, so every re-extraction re-mints it. +// +// Behavior pinned here: a slug-shaped input (contains '/') that fails every +// resolution arm falls back with its path separator INTACT — creating the +// page it names later makes the fact resolvable. Display-name fallback +// (no '/') keeps the existing slugify behavior. + +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { + resolveEntitySlug, + resolveEntitySlugWithSource, +} from '../src/core/entities/resolve.ts'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; + +let engine: PGLiteEngine; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({ database_url: '' }); + await engine.initSchema(); + // Deliberately NO page at people/zeta-nonexistent — the fallback arm is + // exactly the "well-formed slug, page not created yet" case from #3447. +}); + +afterAll(async () => { + await engine.disconnect(); +}); + +describe('resolveEntitySlug fallback preserves slug-shaped input (#3447)', () => { + it('returns a well-formed but unresolvable slug untouched', async () => { + const out = await resolveEntitySlug(engine, 'default', 'people/zeta-nonexistent'); + expect(out).toBe('people/zeta-nonexistent'); + }); + + it('normalizes a messy path-shaped input per segment, keeping the separator', async () => { + const out = await resolveEntitySlug(engine, 'default', 'People/Zeta Nonexistent'); + expect(out).toBe('people/zeta-nonexistent'); + }); + + it('still slugifies display names (no separator) as before', async () => { + const out = await resolveEntitySlug(engine, 'default', 'Zeta Nonexistent Q. Persson'); + expect(out).toBe('zeta-nonexistent-q-persson'); + }); + + it('resolveEntitySlugWithSource fallback_slugify agrees with resolveEntitySlug', async () => { + const out = await resolveEntitySlugWithSource(engine, 'default', 'companies/zeta-widgets-nonexistent'); + expect(out).toEqual({ slug: 'companies/zeta-widgets-nonexistent', source: 'fallback_slugify' }); + }); +});