From b139602119a24678dcc96d8924aec7e9a6716731 Mon Sep 17 00:00:00 2001 From: Time Attakc <89218912+time-attack@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:13:50 -0700 Subject: [PATCH] fix(slugs): CJK slug support in SlugRegistry and dream-cycle summary slug (takeover of #782, #738) (#3083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master already widened slugifySegment (sync.ts) and validatePageSlug (operations.ts) to CJK in v0.32.7, but the other two validators #782 targeted stayed ASCII-only: SlugRegistry's SLUG_RE rejected any CJK desiredSlug from BrainWriter, and synthesize.ts's SUMMARY_SLUG_RE (whose comment claimed it was kept in sync with validatePageSlug) rejected CJK output roots. Hoist the segment grammar into cjk.ts as PAGE_SLUG_SEG and compose all three regex sites from it, so the four slug validators share one grammar. Each site keeps its own shape (SlugRegistry's >=2-segment dir/name form, validatePageSlug's case-insensitive flag). Scope stays CJK (matching v0.32.7), not full \p{L} Unicode as #782 proposed — all-scripts slugs (lookalike/RTL spoofing) is a maintainer policy call. Co-authored-by: Sinabina Co-authored-by: tamagodo-fu Co-authored-by: Claude Fable 5 --- src/core/cjk.ts | 8 ++++++++ src/core/cycle/synthesize.ts | 5 +++-- src/core/operations.ts | 3 +-- src/core/output/slug-registry.ts | 5 ++++- test/cycle-dream-output-root.test.ts | 6 ++++++ test/writer.test.ts | 11 +++++++++++ 6 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/core/cjk.ts b/src/core/cjk.ts index d9c531876..49efab249 100644 --- a/src/core/cjk.ts +++ b/src/core/cjk.ts @@ -20,6 +20,14 @@ export const CJK_SLUG_CHARS = '一-鿿぀-ゟ゠-ヿ가-힯'; export const CJK_RANGES_REGEX = new RegExp(`[${CJK_SLUG_CHARS}]`); +/** + * Page-slug segment grammar (no anchors): alnum-or-CJK lead char, then + * alnum/CJK/hyphen continuation. Single source for validatePageSlug + * (operations.ts), SlugRegistry's SLUG_RE, and the dream-cycle + * SUMMARY_SLUG_RE so every slug validator shares one grammar (#738). + */ +export const PAGE_SLUG_SEG = `[a-z0-9${CJK_SLUG_CHARS}][a-z0-9${CJK_SLUG_CHARS}\\-]*`; + export const CJK_SENTENCE_DELIMITERS = ['。', '!', '?']; // 。!? export const CJK_CLAUSE_DELIMITERS = [';', ':', ',', '、']; // ;:,、 diff --git a/src/core/cycle/synthesize.ts b/src/core/cycle/synthesize.ts index 2dc1a5f7a..813b06a45 100644 --- a/src/core/cycle/synthesize.ts +++ b/src/core/cycle/synthesize.ts @@ -43,10 +43,11 @@ import { serializeMarkdown, serializePageToMarkdown } from '../markdown.ts'; import type { Page, PageType } from '../types.ts'; import { validateSourceId } from '../utils.ts'; import { safeSplitIndex } from '../text-safe.ts'; +import { PAGE_SLUG_SEG } from '../cjk.ts'; -// Slug regex from validatePageSlug — kept in sync. +// Slug grammar from validatePageSlug — shared via PAGE_SLUG_SEG (#738). // Used for the orchestrator-written summary index slug. -const SUMMARY_SLUG_RE = /^[a-z0-9][a-z0-9\-]*(\/[a-z0-9][a-z0-9\-]*)*$/; +const SUMMARY_SLUG_RE = new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})*$`); // ── Model context budget (D1, D5, D7, D9) ───────────────────────────── diff --git a/src/core/operations.ts b/src/core/operations.ts index 0d1e5dcef..ca442c687 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -25,7 +25,7 @@ import { bumpLastRetrievedAt } from './last-retrieved.ts'; import { isSearchMode } from './search/mode.ts'; import { stampEvidence } from './search/evidence.ts'; import type { SearchResult } from './types.ts'; -import { CJK_SLUG_CHARS } from './cjk.ts'; +import { CJK_SLUG_CHARS, PAGE_SLUG_SEG } from './cjk.ts'; import * as db from './db.ts'; import { VERSION } from '../version.ts'; import { @@ -162,7 +162,6 @@ export function validatePageSlug(slug: string): void { } // v0.32.7: CJK ranges (Han / Hiragana / Katakana / Hangul Syllables) allowed // in segments. ASCII shape rules (lead char, hyphen continuation) preserved. - const PAGE_SLUG_SEG = `[a-z0-9${CJK_SLUG_CHARS}][a-z0-9${CJK_SLUG_CHARS}\\-]*`; if (!new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})*$`, 'i').test(slug)) { throw new OperationError('invalid_params', `Invalid page_slug: ${slug} (allowed: alphanumeric, CJK, hyphens, forward-slash separated segments)`); } diff --git a/src/core/output/slug-registry.ts b/src/core/output/slug-registry.ts index 42c7c420b..f7010139c 100644 --- a/src/core/output/slug-registry.ts +++ b/src/core/output/slug-registry.ts @@ -17,6 +17,7 @@ import type { BrainEngine } from '../engine.ts'; import type { PageType } from '../types.ts'; +import { PAGE_SLUG_SEG } from '../cjk.ts'; export interface CreateSlugInput { /** @@ -71,7 +72,9 @@ export class SlugRegistryError extends Error { // SlugRegistry // --------------------------------------------------------------------------- -const SLUG_RE = /^[a-z0-9][a-z0-9\-]*(\/[a-z0-9][a-z0-9\-]*)+$/; +// Shares the page-slug segment grammar (incl. CJK ranges, #738) with +// validatePageSlug; keeps this site's dir/name shape (>= 2 segments). +const SLUG_RE = new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})+$`); export class SlugRegistry { constructor(private engine: BrainEngine) {} diff --git a/test/cycle-dream-output-root.test.ts b/test/cycle-dream-output-root.test.ts index 384a7a64d..6df647de0 100644 --- a/test/cycle-dream-output-root.test.ts +++ b/test/cycle-dream-output-root.test.ts @@ -86,6 +86,12 @@ describe('#2415: loadOutputRoot validation + patterns gather scope', () => { expect(await loadOutputRoot(engine)).toBe('wiki'); }); + test('CJK root passes the slug grammar (#738)', async () => { + await engine.setConfig('dream.synthesize.output_root', '知识/笔记'); + expect(await loadOutputRoot(engine)).toBe('知识/笔记'); + await engine.setConfig('dream.synthesize.output_root', ''); + }); + test('patterns phase gathers reflections under the configured root', async () => { await engine.setConfig('dream.synthesize.output_root', 'notes'); for (let i = 0; i < 3; i++) { diff --git a/test/writer.test.ts b/test/writer.test.ts index 1bf1d60e4..53cefca71 100644 --- a/test/writer.test.ts +++ b/test/writer.test.ts @@ -203,6 +203,17 @@ describe('SlugRegistry', () => { })).rejects.toThrow(SlugRegistryError); }); + test('create accepts CJK slugs (#738)', async () => { + const reg = new SlugRegistry(engine); + const r = await reg.create({ + desiredSlug: '知识/品牌圣经', + displayName: '品牌圣经', + type: 'note', + }); + expect(r.slug).toBe('知识/品牌圣经'); + expect(r.exact).toBe(true); + }); + test('create throws on invalid slug', async () => { const reg = new SlugRegistry(engine); await expect(reg.create({