Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/contracts/src/orchestration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1566,6 +1566,37 @@ it.effect("project monograms validate text and palette colors", () =>
}),
);

it.effect("project monograms count graphemes without Intl.Segmenter", () =>
Effect.gen(function* () {
// Hermes (Android/iOS) has no Intl.Segmenter, so the schema counts with a
// runtime-independent approximation. ZWJ joins backward only (GB9, no
// emoji context in the monogram charset), Hangul Jamo compose per
// GB6-GB8, and Indic conjuncts join per GB9c.
for (const text of ["A‍B", "가나", "가나", "किखि", "é", "क्ष", "क्षA", "क्‍ष"]) {
const result = yield* Effect.exit(
decodeOrchestrationCommand({
type: "project.meta.update",
commandId: "cmd-monogram-grapheme",
projectId: "project-1",
projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: text },
}),
);
assert.strictEqual(result._tag, "Success");
}
for (const text of ["A‍B‍C", "가나가", "ᄀᆨᄀ", "किखिगि", "ABC", "क्षAB"]) {
const result = yield* Effect.exit(
decodeOrchestrationCommand({
type: "project.meta.update",
commandId: "cmd-monogram-grapheme-invalid",
projectId: "project-1",
projectIcon: { kind: "lucide", name: "folder-code", color: "blue", monogram: text },
}),
);
assert.strictEqual(result._tag, "Failure");
}
}),
);

it.effect("rejects thread history imports without messages", () =>
Effect.gen(function* () {
const result = yield* Effect.exit(
Expand Down
83 changes: 73 additions & 10 deletions packages/contracts/src/orchestration.ts

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should prob move this out of orchestrator.ts now hehe

Original file line number Diff line number Diff line change
Expand Up @@ -461,23 +461,86 @@ const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32));

// Hermes (the Android/iOS JS runtime) has no Intl.Segmenter, and this module
// loads at app startup, so `new Intl.Segmenter()` crashes mobile on launch.
// Count grapheme clusters with a small approximation instead: a code point
// continues the current cluster when it is a combining mark, joiner,
// variation selector, or emoji modifier, or follows a ZWJ. This must stay
// Count grapheme clusters with a UAX-29 approximation instead. This must stay
// runtime-independent (not `typeof Intl.Segmenter` feature detection) so the
// shared contract validates identically on server, web, and mobile.
const GRAPHEME_CONTINUATION = /[\p{M}\u200c\u200d\ufe0f\u{1F3FB}-\u{1F3FF}]/u;
//
// Covered rules: GB9/GB9a (Extend, SpacingMark, and ZWJ continue the preceding
// cluster), GB6-GB8 (Hangul L/V/T/LV/LVT composition), and GB9c (Indic
// conjuncts: Consonant (Extend|Linker)* Linker (Extend|Linker)* joins the
// following Consonant). GB11 emoji-ZWJ joining, GB12-GB13 flag pairing, GB9b
// Prepend, and control handling are out of scope: none of those characters
// pass the monogram pattern below, so they cannot affect validation.
//
// The GB9c tables and the Extend/SpacingMark refinements were derived from
// the UCD (GraphemeBreakProperty.txt) plus empirical probing of every L/N/M
// code point in each GB9c position against Node's Intl.Segmenter (ICU 78 /
// Unicode 17); re-derive if the Unicode version moves, since the Linker set
// in particular grows over time.
const GRAPHEME_EXTEND_BASE =
/[\p{M}\u200c\u200d\ufe0f\u{1F3FB}-\u{1F3FF}\u{E33}\u{EB3}\u{1ACF}-\u{1ADD}\u{1AE0}-\u{1AEB}\u{FF9E}-\u{FF9F}\u{10EFA}-\u{10EFB}\u{11B60}-\u{11B67}\u{1E6E3}\u{1E6E6}\u{1E6EE}-\u{1E6EF}\u{1E6F5}\u{E0020}-\u{E007F}]/u;
// Marks with GCB=Other (e.g. Myanmar AA U+102C): look like marks but break.
const GRAPHEME_EXTEND_EXCEPT =
/[\u{102B}-\u{102C}\u{1038}\u{1062}-\u{1064}\u{1067}-\u{106D}\u{1083}\u{1087}-\u{108C}\u{108F}\u{109A}-\u{109C}\u{1A61}\u{1A63}-\u{1A64}\u{AA7B}\u{AA7D}\u{11720}-\u{11721}]/u;
const CONJUNCT_LINKER =
/[\u{94D}\u{9CD}\u{ACD}\u{B4D}\u{C4D}\u{D4D}\u{1039}\u{17D2}\u{1A60}\u{1B44}\u{1BAB}\u{A9C0}\u{AAF6}\u{10A3F}\u{11133}\u{113D0}\u{1193E}\u{11A47}\u{11A99}\u{11F42}]/u;
const CONJUNCT_MIDDLE =
/[\p{Mn}\p{Me}\u{9BE}\u{9D7}\u{B3E}\u{B57}\u{BBE}\u{BD7}\u{CC0}\u{CC2}\u{CC7}-\u{CC8}\u{CCA}-\u{CCB}\u{CD5}-\u{CD6}\u{D3E}\u{D57}\u{DCF}\u{DDF}\u{1715}\u{1734}\u{1ACF}-\u{1ADD}\u{1AE0}-\u{1AEB}\u{1B35}\u{1B3B}\u{1B3D}\u{1B43}-\u{1B44}\u{1BAA}\u{1BF2}-\u{1BF3}\u{200D}\u{302E}-\u{302F}\u{A953}\u{A9C0}\u{FF9E}-\u{FF9F}\u{10EFA}-\u{10EFB}\u{111C0}\u{11235}\u{1133E}\u{1134D}\u{11357}\u{113B8}\u{113C2}\u{113C5}\u{113C7}-\u{113C9}\u{113CF}\u{114B0}\u{114BD}\u{115AF}\u{116B6}\u{11930}\u{1193D}\u{11B60}\u{11B62}-\u{11B64}\u{11B66}\u{11F41}\u{16FF0}-\u{16FF1}\u{1D165}-\u{1D166}\u{1D16D}-\u{1D172}\u{1E6E3}\u{1E6E6}\u{1E6EE}-\u{1E6EF}\u{1E6F5}]/u;
const CONJUNCT_CONSONANT =
/[\u{915}-\u{939}\u{958}-\u{95F}\u{978}-\u{97F}\u{995}-\u{9A8}\u{9AA}-\u{9B0}\u{9B2}\u{9B6}-\u{9B9}\u{9DC}-\u{9DD}\u{9DF}\u{9F0}-\u{9F1}\u{A95}-\u{AA8}\u{AAA}-\u{AB0}\u{AB2}-\u{AB3}\u{AB5}-\u{AB9}\u{AF9}\u{B15}-\u{B28}\u{B2A}-\u{B30}\u{B32}-\u{B33}\u{B35}-\u{B39}\u{B5C}-\u{B5D}\u{B5F}\u{B71}\u{C15}-\u{C28}\u{C2A}-\u{C39}\u{C58}-\u{C5A}\u{D15}-\u{D3A}\u{1000}-\u{102A}\u{103F}\u{1050}-\u{1055}\u{105A}-\u{105D}\u{1061}\u{1065}-\u{1066}\u{106E}-\u{1070}\u{1075}-\u{1081}\u{108E}\u{1780}-\u{17B3}\u{1A20}-\u{1A54}\u{1B0B}-\u{1B0C}\u{1B13}-\u{1B33}\u{1B45}-\u{1B4C}\u{1B83}-\u{1BA0}\u{1BAE}-\u{1BAF}\u{1BBB}-\u{1BBD}\u{A989}-\u{A98B}\u{A98F}-\u{A9B2}\u{A9E0}-\u{A9E4}\u{A9E7}-\u{A9EF}\u{A9FA}-\u{A9FE}\u{AA60}-\u{AA6F}\u{AA71}-\u{AA73}\u{AA7A}\u{AA7E}-\u{AA7F}\u{AAE0}-\u{AAEA}\u{ABC0}-\u{ABDA}\u{10A00}\u{10A10}-\u{10A13}\u{10A15}-\u{10A17}\u{10A19}-\u{10A35}\u{11103}-\u{11126}\u{11144}\u{11147}\u{11380}-\u{11389}\u{1138B}\u{1138E}\u{11390}-\u{113B5}\u{11900}-\u{11906}\u{11909}\u{1190C}-\u{11913}\u{11915}-\u{11916}\u{11918}-\u{1192F}\u{11A00}\u{11A0B}-\u{11A32}\u{11A50}\u{11A5C}-\u{11A83}\u{11F04}-\u{11F10}\u{11F12}-\u{11F33}]/u;
type HangulType = "L" | "V" | "T" | "LV" | "LVT";

const hangulType = (codePoint: number): HangulType | null => {
if ((codePoint >= 0x1100 && codePoint <= 0x115f) || (codePoint >= 0xa960 && codePoint <= 0xa97c))
return "L";
if ((codePoint >= 0x1160 && codePoint <= 0x11a7) || (codePoint >= 0xd7b0 && codePoint <= 0xd7c6))
return "V";
if ((codePoint >= 0x11a8 && codePoint <= 0x11ff) || (codePoint >= 0xd7cb && codePoint <= 0xd7fb))
return "T";
if (codePoint >= 0xac00 && codePoint <= 0xd7a3)
return (codePoint - 0xac00) % 28 === 0 ? "LV" : "LVT";
return null;
};

const countGraphemes = (text: string): number => {
let clusters = 0;
let prevJoiner = false;
let prevHangul: HangulType | null = null;
// GB9c left context: 0 none, 1 Consonant, 2 +middles, 3 +Linker seen. A
// Linker with no leading Consonant (state 0) opens nothing, so a leading
// virama still breaks before the following consonant.
let conjunct = 0;
let started = false;
for (const char of text) {
if (clusters > 0 && (prevJoiner || GRAPHEME_CONTINUATION.test(char))) {
prevJoiner = char === "\u200d";
continue;
const cp = char.codePointAt(0) ?? 0;
const hangul = hangulType(cp);
const extend = GRAPHEME_EXTEND_BASE.test(char) && !GRAPHEME_EXTEND_EXCEPT.test(char);
const linker = CONJUNCT_LINKER.test(char);
const middle = linker || CONJUNCT_MIDDLE.test(char);
const consonant = CONJUNCT_CONSONANT.test(char);
let boundary = true;
if (started) {
if (extend) boundary = false;
else if (conjunct === 3 && consonant) boundary = false;
else if (
prevHangul === "L" &&
(hangul === "L" || hangul === "V" || hangul === "LV" || hangul === "LVT")
)
boundary = false;
else if ((prevHangul === "LV" || prevHangul === "V") && (hangul === "V" || hangul === "T"))
boundary = false;
else if ((prevHangul === "LVT" || prevHangul === "T") && hangul === "T") boundary = false;
}
if (boundary) {
clusters += 1;
started = true;
}
clusters += 1;
prevJoiner = char === "\u200d";
if (consonant) conjunct = 1;
else if (middle || linker) {
if (linker) {
if (conjunct !== 0) conjunct = 3;
} else if (conjunct === 1 || conjunct === 2) conjunct = 2;
} else conjunct = 0;
prevHangul = hangul;
Comment thread
bompus marked this conversation as resolved.
}
return clusters;
};
Expand Down
Loading