Compare commits

...
Author SHA1 Message Date
Time Attakc adde3a9e8a Merge branch 'master' into fix/entity-slug-fallback-3447 2026-08-01 05:15:40 +08:00
Garry TanandClaude Opus 5 b51befc42b fix(entities): keep the path separator in resolveEntitySlug's slugify fallback (#3447)
slugify()'s [^a-z0-9]+ -> '-' rule rewrote '/' in slug-shaped fallback
input, minting entity_slugs no page can ever have (people/alice-example
-> people-alice-example) — self-perpetuating on every re-extraction.
Both resolvers now route through one fallbackSlugify() that slugifies
path-shaped input per segment (identity for well-formed slugs) and
keeps plain slugify for display names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:26:57 -07:00
2 changed files with 69 additions and 1 deletions
+16 -1
View File
@@ -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' };
}
/**
+53
View File
@@ -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' });
});
});