diff --git a/loopx/control_plane/work_items/task_lease_acquire_decision.ts b/loopx/control_plane/work_items/task_lease_acquire_decision.ts index d116285b90..3b3df2ad7f 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -140,52 +140,249 @@ function optionalInteger(value: unknown, label: string): number | null { return value; } -function classMatch( - pattern: string, - start: number, - value: string, -): { end: number; matches: boolean } | null { - const end = characterClassEnd(pattern, start); - if (end < 0) return null; - let body = pattern.slice(start + 1, end); - const negated = body.startsWith("!"); - if (negated) body = body.slice(1); - let matches = false; - for (let index = 0; index < body.length; index += 1) { - if (index + 2 < body.length && body[index + 1] === "-") { - if (body[index] <= value && value <= body[index + 2]) matches = true; +/** + * Canonical fold for one code point, iterated until it is stable. Host case + * aliases are symmetric (Σ/ς, K/KELVIN SIGN) and some change length (ß -> ss), + * so one upper/lower pass is not an equivalence relation on its own. + */ +function foldCharacter(character: string): string { + let folded = character; + // Two passes settle every code point in current Unicode data; the bound only + // guards against a future mapping that never reaches a fixed point. + for (let pass = 0; pass < 4; pass += 1) { + const next = folded.toUpperCase().toLowerCase(); + if (next === folded) return folded; + folded = next; + } + return folded; +} + +/** + * Folding one code point at a time keeps the fold composable across a path, so a + * comparison-only view can fold substrings without changing the retained + * spelling or the immutable request identity. + */ +function caseFold(value: string): string { + let folded = ""; + for (const character of value) folded += foldCharacter(character); + return folded; +} + +/** The code point of single-character text, or null for longer text. */ +function singleCodePoint(value: string): string | null { + const code = value.codePointAt(0); + if (code === undefined) return null; + const character = String.fromCodePoint(code); + return character.length === value.length ? character : null; +} + +type GlobToken = + | {kind: "literal"; folded: string} + | {kind: "star"} + | {kind: "question"} + | {kind: "class"; body: string; negated: boolean}; + +function globTokens(pattern: string): GlobToken[] { + const tokens: GlobToken[] = []; + let literal = ""; + const flushLiteral = () => { + if (literal.length === 0) return; + tokens.push({kind: "literal", folded: caseFold(literal)}); + literal = ""; + }; + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index]; + if (character === "*") { + flushLiteral(); + tokens.push({kind: "star"}); + continue; + } + if (character === "?") { + flushLiteral(); + tokens.push({kind: "question"}); + continue; + } + if (character === "[") { + const end = characterClassEnd(pattern, index); + if (end >= 0) { + flushLiteral(); + let body = pattern.slice(index + 1, end); + const negated = body.startsWith("!"); + if (negated) body = body.slice(1); + tokens.push({kind: "class", body, negated}); + index = end; + continue; + } + } + literal += character; + } + flushLiteral(); + return tokens; +} + +type CharacterClass = { + literals: readonly string[]; + ranges: readonly (readonly [number, number])[]; +}; + +/** Class members are code points, and a range keeps its original interval bounds. */ +function characterClass(body: string): CharacterClass { + const members = [...body]; + const literals: string[] = []; + const ranges: Array = []; + for (let index = 0; index < members.length; index += 1) { + const high = index + 2 < members.length && members[index + 1] === "-" + ? members[index + 2].codePointAt(0) + : undefined; + const low = high === undefined ? undefined : members[index].codePointAt(0); + if (low !== undefined && high !== undefined) { + ranges.push([low, high]); index += 2; - } else if (body[index] === value) { - matches = true; + } else { + literals.push(members[index]); } } - return { end, matches: negated ? !matches : matches }; + return {literals, ranges}; +} + +function classContains(members: CharacterClass, character: string): boolean { + const code = character.codePointAt(0); + if (code === undefined) return false; + return members.literals.includes(character) || + members.ranges.some(([low, high]) => low <= code && code <= high); } +/** + * Code points whose canonical fold is not themselves, grouped by fold value. + * Class membership needs this inverse: walking an interval is unbounded, while a + * fold class holds at most a handful of code points, so an interval of any width + * is decided by a few comparisons. The inverse is only discoverable by folding + * the code space, so it is built once on first use. + */ +let foldPreimages: ReadonlyMap | null = null; + +function foldPreimagesByFold(): ReadonlyMap { + const cached = foldPreimages; + if (cached !== null) return cached; + const preimages = new Map(); + for (let start = 0; start <= 0x10ffff; start += 0x400) { + const end = Math.min(start + 0x3ff, 0x10ffff); + let block = ""; + for (let code = start; code <= end; code += 1) { + if (code < 0xd800 || code > 0xdfff) block += String.fromCodePoint(code); + } + // A block without a case mapping cannot hold a non-identity fold. + if (block.toUpperCase() === block && block.toLowerCase() === block) continue; + for (let code = start; code <= end; code += 1) { + if (code >= 0xd800 && code <= 0xdfff) continue; + const character = String.fromCodePoint(code); + const folded = foldCharacter(character); + if (folded === character) continue; + const bucket = preimages.get(folded); + if (bucket === undefined) preimages.set(folded, [character]); + else bucket.push(character); + } + } + foldPreimages = preimages; + return preimages; +} + +/** Longest fold one code point expands to; derived rather than assumed. */ +let foldConsumptionLimit = 0; + +function maximumFoldConsumption(): number { + if (foldConsumptionLimit === 0) { + let limit = 1; + for (const folded of foldPreimagesByFold().keys()) { + limit = Math.max(limit, [...folded].length); + } + foldConsumptionLimit = limit; + } + return foldConsumptionLimit; +} + +/** + * A class member covers the compared text when the two share a fold value, so + * `[Σ]` covers ς, `[ß]` covers SS, and `[ß-…]` covers both through its own + * bounds. Fold classes are compared instead of walking interval members, so a + * wide range costs the same as a narrow one. + */ +function classMemberMatches(body: string, candidate: string): boolean { + const members = characterClass(body); + const folded = caseFold(candidate); + const preimages = foldPreimagesByFold().get(folded) ?? []; + if (preimages.some((character) => classContains(members, character))) return true; + // A single code point whose fold is itself is its own preimage. + const own = singleCodePoint(folded); + return own !== null && classContains(members, own); +} + +/** + * A class consumes one code point by default, and several when the compared text + * folds to the same value as a member (ß/SS, ffi/FFI). + */ +function classMatchLengths(body: string, symbols: readonly string[], start: number): number[] { + const lengths: number[] = []; + for (let length = 1; length <= maximumFoldConsumption() && start + length <= symbols.length; length += 1) { + if (classMemberMatches(body, symbols.slice(start, start + length).join(""))) { + lengths.push(length); + } + } + return lengths; +} + +/** A folded span can stand for one code point when it is a one-code-point fold or a mapped character. */ +function singleCharacterMatchLengths(symbols: readonly string[], start: number): number[] { + const lengths: number[] = []; + const preimages = foldPreimagesByFold(); + for (let length = 1; length <= maximumFoldConsumption() && start + length <= symbols.length; length += 1) { + const candidate = symbols.slice(start, start + length).join(""); + if ((length === 1 && caseFold(candidate) === candidate) || preimages.has(candidate)) { + lengths.push(length); + } + } + return lengths; +} + +/** + * Match against the compared path's folded code points. One original code point + * may fold to several symbols (ß/SS), so token boundaries intentionally operate + * on the folded stream rather than assuming one symbol per host character. + */ function fnmatchcase(value: string, pattern: string): boolean { + const symbols = [...caseFold(value)]; + const tokens = globTokens(pattern); const memo = new Map(); - const match = (valueIndex: number, patternIndex: number): boolean => { - const key = `${valueIndex}:${patternIndex}`; + const match = (valueIndex: number, tokenIndex: number): boolean => { + const key = `${valueIndex}:${tokenIndex}`; const cached = memo.get(key); if (cached !== undefined) return cached; let result: boolean; - if (patternIndex === pattern.length) { - result = valueIndex === value.length; - } else if (pattern[patternIndex] === "*") { - result = match(valueIndex, patternIndex + 1) || - (valueIndex < value.length && match(valueIndex + 1, patternIndex)); - } else if (valueIndex === value.length) { - result = false; - } else if (pattern[patternIndex] === "?") { - result = match(valueIndex + 1, patternIndex + 1); - } else if (pattern[patternIndex] === "[") { - const characterClass = classMatch(pattern, patternIndex, value[valueIndex]); - result = characterClass === null - ? value[valueIndex] === "[" && match(valueIndex + 1, patternIndex + 1) - : characterClass.matches && match(valueIndex + 1, characterClass.end + 1); + if (tokenIndex === tokens.length) { + result = valueIndex === symbols.length; } else { - result = value[valueIndex] === pattern[patternIndex] && - match(valueIndex + 1, patternIndex + 1); + const token = tokens[tokenIndex]; + if (token.kind === "star") { + result = match(valueIndex, tokenIndex + 1) || + (valueIndex < symbols.length && match(valueIndex + 1, tokenIndex)); + } else if (valueIndex === symbols.length) { + result = false; + } else if (token.kind === "question") { + result = singleCharacterMatchLengths(symbols, valueIndex) + .some((length) => match(valueIndex + length, tokenIndex + 1)); + } else if (token.kind === "class") { + const lengths = classMatchLengths(token.body, symbols, valueIndex); + result = token.negated + ? singleCharacterMatchLengths(symbols, valueIndex).some((length) => + !lengths.includes(length) && match(valueIndex + length, tokenIndex + 1)) + : lengths.some((length) => match(valueIndex + length, tokenIndex + 1)); + } else { + const literal = [...token.folded]; + const end = valueIndex + literal.length; + result = end <= symbols.length && + literal.every((symbol, index) => symbols[valueIndex + index] === symbol) && + match(end, tokenIndex + 1); + } } memo.set(key, result); return result; @@ -201,7 +398,9 @@ function scopeLiteralPrefix(scope: string): string { } function scopePairOverlaps(left: string, right: string): boolean { - if (left === right) return true; + const equal = (first: string, second: string) => first === second || + caseFold(first) === caseFold(second); + if (equal(left, right)) return true; if (["*", "**", "./"].includes(left) || ["*", "**", "./"].includes(right)) { return true; } @@ -210,23 +409,23 @@ function scopePairOverlaps(left: string, right: string): boolean { if (leftGlob && !rightGlob) { const prefix = scopeLiteralPrefix(left); return fnmatchcase(right, left) || - (prefix.endsWith("/") && right.replace(/\/$/u, "") === prefix.replace(/\/$/u, "")); + (prefix.endsWith("/") && equal(right.replace(/\/$/u, ""), prefix.replace(/\/$/u, ""))); } if (rightGlob && !leftGlob) { const prefix = scopeLiteralPrefix(right); return fnmatchcase(left, right) || - (prefix.endsWith("/") && left.replace(/\/$/u, "") === prefix.replace(/\/$/u, "")); + (prefix.endsWith("/") && equal(left.replace(/\/$/u, ""), prefix.replace(/\/$/u, ""))); } if (leftGlob && rightGlob) { - const leftPrefix = scopeLiteralPrefix(left); - const rightPrefix = scopeLiteralPrefix(right); + const leftPrefix = caseFold(scopeLiteralPrefix(left)); + const rightPrefix = caseFold(scopeLiteralPrefix(right)); return !leftPrefix || !rightPrefix || leftPrefix.startsWith(rightPrefix) || rightPrefix.startsWith(leftPrefix); } - const leftRoot = left.replace(/\/$/u, ""); - const rightRoot = right.replace(/\/$/u, ""); - return (left.endsWith("/") && right.startsWith(`${leftRoot}/`)) || - (right.endsWith("/") && left.startsWith(`${rightRoot}/`)); + const leftRoot = caseFold(left.replace(/\/$/u, "")); + const rightRoot = caseFold(right.replace(/\/$/u, "")); + return (left.endsWith("/") && caseFold(right).startsWith(`${leftRoot}/`)) || + (right.endsWith("/") && caseFold(left).startsWith(`${rightRoot}/`)); } function writeScopesOverlap(left: readonly string[], right: readonly string[]): boolean { diff --git a/tests/control_plane/test_canonical_lease_scopes.py b/tests/control_plane/test_canonical_lease_scopes.py index e32eb40a88..f56da30279 100644 --- a/tests/control_plane/test_canonical_lease_scopes.py +++ b/tests/control_plane/test_canonical_lease_scopes.py @@ -39,7 +39,7 @@ def cli(*args, cwd=None, expected_exit=0): selected_repo = Path(os.environ.get("LOOPX_LEASE_SCOPE_CONSUMER", REPO)) if cwd is None else cwd child = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", *args, "--goal-id", goal], - cwd=selected_repo, capture_output=True, text=True, timeout=60, check=False) + cwd=selected_repo, capture_output=True, text=True, encoding="utf-8", timeout=60, check=False) assert child.returncode == expected_exit, child.stdout + child.stderr return json.loads(child.stdout) @@ -98,10 +98,102 @@ def test_authored_alias_declaration_can_be_claimed_without_revision(scope_goal): assert cli("todo", "list", "--todo-id", todo_id)["todo"]["required_write_scopes"] == ALIASES +def test_case_aliases_remain_exclusive_write_scopes(scope_goal): + cli, current = scope_goal + acquire = ("task-lease", "acquire", "--todo-id", "todo_scope_a", "--owner", "agent-a", + "--idempotency-key", "case-a", "--expected-version", "0", "--write-scope", "src/Main.ts") + assert cli(*acquire)["acquired"] + assert cli(*acquire)["idempotent"] + changed = cli(*acquire[:-1], "src/main.ts", expected_exit=1) + assert changed["error_code"] == "coordination_operation_identity_mismatch" + before = current() + rejected = cli("task-lease", "acquire", "--todo-id", "todo_scope_b", "--owner", "agent-b", + "--idempotency-key", "case-b", "--expected-version", "0", "--write-scope", "src/main.ts", + expected_exit=1) + assert rejected["error_code"] == "write_scope_conflict" + assert current() == before + + +@pytest.mark.parametrize("first_scope,second_scope", [ + ("src/Σ.ts", "src/ς.ts"), + ("src/ß.ts", "src/SS.ts"), + ("src/[ρ-ς].ts", "src/Σ.ts"), + ("src/[ß-\u20df].ts", "src/SS.ts"), + ("src/SS.ts", "src/[ß-\u20df].ts"), + ("src/[\u212a-\u412a].ts", "src/K.ts"), + ("src/?.ts", "src/SS.ts"), + ("src/SS.ts", "src/?.ts"), + ("src/S?.ts", "src/ß.ts"), + ("src/ß.ts", "src/S?.ts"), + ("src/[S]S.ts", "src/ß.ts"), + ("src/ß.ts", "src/[S]S.ts"), + ("src/[!Σ].ts", "src/SS.ts"), + ("src/SS.ts", "src/[!Σ].ts"), + ("src/[!Σ]S.ts", "src/SSS.ts"), + ("src/SSS.ts", "src/[!Σ]S.ts"), +]) +def test_unicode_case_aliases_remain_exclusive_write_scopes(scope_goal, first_scope, second_scope): + cli, current = scope_goal + acquired = cli("task-lease", "acquire", "--todo-id", "todo_scope_a", "--owner", "agent-a", + "--idempotency-key", "unicode-a", "--expected-version", "0", "--write-scope", first_scope) + assert acquired["lease"]["write_scopes"] == [first_scope] + before = current() + rejected = cli("task-lease", "acquire", "--todo-id", "todo_scope_b", "--owner", "agent-b", + "--idempotency-key", "unicode-b", "--expected-version", "0", "--write-scope", second_scope, + expected_exit=1) + assert rejected["error_code"] == "write_scope_conflict" + assert current() == before + + +@pytest.mark.parametrize("first_scope,second_scope", [ + ("src/Σ.ts", "src/ς.ts"), + ("src/ß.ts", "src/SS.ts"), + ("src/[ρ-ς].ts", "src/Σ.ts"), + ("src/[ß-\u20df].ts", "src/SS.ts"), + ("src/[\u212a-\u412a].ts", "src/K.ts"), + ("src/?.ts", "src/SS.ts"), + ("src/SS.ts", "src/?.ts"), + ("src/S?.ts", "src/ß.ts"), + ("src/ß.ts", "src/S?.ts"), + ("src/[S]S.ts", "src/ß.ts"), + ("src/ß.ts", "src/[S]S.ts"), + ("src/[!Σ].ts", "src/SS.ts"), + ("src/SS.ts", "src/[!Σ].ts"), + ("src/[!Σ]S.ts", "src/SSS.ts"), + ("src/SSS.ts", "src/[!Σ]S.ts"), +]) +def test_unicode_case_aliases_conflict_at_atomic_todo_claim(scope_goal, first_scope, second_scope): + cli, current = scope_goal + repository = "git:github.com/example/project" + + def add_todo(label, scope): + return cli("todo", "add", "--role", "agent", "--priority", "P1", "--text", f"Unicode scope {label}", + "--task-repository", repository, "--required-write-scope", scope, + "--operation-id", f"unicode-add-{label}")["todo_id"] + + first = add_todo("a", first_scope) + claimed = cli("todo", "claim", "--todo-id", first, "--claimed-by", "agent-a", "--agent-id", "agent-a", + "--claim-operation-id", "unicode-claim-a", "--task-lease-idempotency-key", "unicode-claim-a", + "--task-lease-expected-version", "0") + assert claimed["ok"] + assert cli("task-lease", "inspect", "--todo-id", first)["lease"]["write_scopes"] == [first_scope] + + second = add_todo("b", second_scope) + before = current() + rejected = cli("todo", "claim", "--todo-id", second, "--claimed-by", "agent-b", "--agent-id", "agent-b", + "--claim-operation-id", "unicode-claim-b", "--task-lease-idempotency-key", "unicode-claim-b", + "--task-lease-expected-version", "0", expected_exit=1) + assert rejected["error_code"] == "write_scope_conflict" + assert current() == before + + @pytest.mark.parametrize("scope,target", [ ("src/[]/./]x", "src/.x"), ("src/[]/./]x", "src/]x"), ("src/[!]/./]x", "src/ax"), + ("src/[A-b]x", "src/cx"), + ("src/[ς].ts", "src/Σ.ts"), + ("src/[ß].ts", "src/SS.ts"), ]) def test_glob_class_members_keep_exclusive_scope_coverage(scope_goal, scope, target): cli, current = scope_goal @@ -112,3 +204,64 @@ def test_glob_class_members_keep_exclusive_scope_coverage(scope_goal, scope, tar rejected = cli("task-lease", "acquire", "--todo-id", "todo_scope_b", "--owner", "agent-b", "--idempotency-key", "class-b", "--write-scope", target, "--expected-version", "0", expected_exit=1) assert rejected["error_code"] == "write_scope_conflict" and current() == before + + +@pytest.mark.parametrize("scope,target", [ + ("src/[ς].ts", "src/Σ.ts"), + ("src/[ß].ts", "src/SS.ts"), +]) +def test_unicode_glob_class_aliases_conflict_at_atomic_todo_claim(scope_goal, scope, target): + cli, current = scope_goal + repository = "git:github.com/example/project" + + def add_todo(label, write_scope): + return cli("todo", "add", "--role", "agent", "--priority", "P1", + "--text", f"Unicode class scope {label}", "--task-repository", repository, + "--required-write-scope", write_scope, + "--operation-id", f"unicode-class-add-{label}")["todo_id"] + + first = add_todo("a", scope) + claimed = cli("todo", "claim", "--todo-id", first, "--claimed-by", "agent-a", "--agent-id", "agent-a", + "--claim-operation-id", "unicode-class-claim-a", + "--task-lease-idempotency-key", "unicode-class-claim-a", + "--task-lease-expected-version", "0") + assert claimed["ok"] + assert cli("task-lease", "inspect", "--todo-id", first)["lease"]["write_scopes"] == [scope] + + second = add_todo("b", target) + before = current() + rejected = cli("todo", "claim", "--todo-id", second, "--claimed-by", "agent-b", "--agent-id", "agent-b", + "--claim-operation-id", "unicode-class-claim-b", + "--task-lease-idempotency-key", "unicode-class-claim-b", + "--task-lease-expected-version", "0", expected_exit=1) + assert rejected["error_code"] == "write_scope_conflict" + assert current() == before + + +def test_case_folded_negated_glob_class_excludes_case_variant(scope_goal): + cli, _ = scope_goal + acquired = cli("task-lease", "acquire", "--todo-id", "todo_scope_a", "--owner", "agent-a", + "--idempotency-key", "case-class-a", "--write-scope", "src/[!A-b]x", "--expected-version", "0") + assert acquired["acquired"] + allowed = cli("task-lease", "acquire", "--todo-id", "todo_scope_b", "--owner", "agent-b", + "--idempotency-key", "case-class-b", "--write-scope", "src/cx", "--expected-version", "0") + assert allowed["acquired"] + + +@pytest.mark.parametrize("scope,target", [ + ("src/[!Σ]x", "src/ςx"), + ("src/[!ς]x", "src/Σx"), + ("src/[!ß]x", "src/SSx"), + ("src/[!ß-\u20df].ts", "src/SS.ts"), + ("src/[\u212a-\u412a].ts", "src/i.ts"), + ("src/[!ß].ts", "src/SS.ts"), + ("src/[!Σ].ts", "src/Σ.ts"), +]) +def test_case_folded_negated_glob_class_excludes_unicode_alias(scope_goal, scope, target): + cli, _ = scope_goal + acquired = cli("task-lease", "acquire", "--todo-id", "todo_scope_a", "--owner", "agent-a", + "--idempotency-key", "unicode-negated-a", "--write-scope", scope, "--expected-version", "0") + assert acquired["acquired"] + allowed = cli("task-lease", "acquire", "--todo-id", "todo_scope_b", "--owner", "agent-b", + "--idempotency-key", "unicode-negated-b", "--write-scope", target, "--expected-version", "0") + assert allowed["acquired"] diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index 741c7dccbc..63e2186c86 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -270,6 +270,104 @@ test("scope comparison canonicalizes aliases while request identity keeps its sp true, ); }); +test("scope comparison folds case aliases without rewriting retained or request spelling", () => { + assert.deepEqual(normalizeWriteScopes(["src/Main.ts"]), ["src/Main.ts"]); + assert.deepEqual(comparisonWriteScopes(["src/Main.ts"]), ["src/Main.ts"]); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/Main.ts"], + right: ["src/main.ts"], + }).overlap, true); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/[A-Z]ain.ts"], + right: ["src/main.ts"], + }).overlap, true); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/[A-z]x"], + right: ["src/_x"], + }).overlap, true); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/[A-b]x"], + right: ["src/cx"], + }).overlap, true); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/[!A-b]x"], + right: ["src/cx"], + }).overlap, false); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/Main/"], + right: ["src/main/file.ts"], + }).overlap, true); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/Main/[a-z]*.ts"], + right: ["src/main/[A-Z]*.ts"], + }).overlap, true); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/Main.ts"], + right: ["docs/main.ts"], + }).overlap, false); + for (const [left, right] of [ + ["src/Σ.ts", "src/ς.ts"], + ["src/ß.ts", "src/SS.ts"], + ["src/Σ/", "src/ς/file.ts"], + ["src/ß/*.ts", "src/SS/file.ts"], + ["src/[Σ]x", "src/ςx"], + ["src/[ς]x", "src/Σx"], + ["src/[ß]x", "src/SSx"], + ["src/[ς-ς].ts", "src/Σ.ts"], + ["src/[ß-ß].ts", "src/SS.ts"], + ["src/[!ß]S.ts", "src/SS.ts"], + ["src/[ρ-ς].ts", "src/Σ.ts"], + ["src/[ρ-ς].ts", "src/σ.ts"], + ["src/[ß-à].ts", "src/SS.ts"], + // A host character may be spelled by several code points. Glob tokens see + // the folded stream, so question, class and literal boundaries compose. + ["src/?.ts", "src/SS.ts"], + ["src/S?.ts", "src/ß.ts"], + ["src/[S]S.ts", "src/ß.ts"], + ["src/???.ts", "src/ffi.ts"], + ["src/[!Σ].ts", "src/SS.ts"], + ["src/[!Σ]S.ts", "src/SSS.ts"], + // Wide intervals keep their original bounds, so the alias is found inside + // the interval instead of by walking or widening it. + ["src/[ß-\u20df].ts", "src/SS.ts"], + ["src/[ß-\u20df].ts", "src/ẞ.ts"], + ["src/[\u212a-\u412a].ts", "src/K.ts"], + ]) { + assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, true, `${left} overlaps ${right}`); + } + for (const [left, right] of [ + ["src/[!Σ]x", "src/ςx"], + ["src/[!ς]x", "src/Σx"], + ["src/[!ß]x", "src/SSx"], + ["src/[!ß]S.ts", "src/S.ts"], + ["src/[ß-ß].ts", "src/S.ts"], + ["src/[!ρ-ς].ts", "src/Σ.ts"], + ["src/[!ß-\u20df].ts", "src/SS.ts"], + ["src/[\u212a-\u412a].ts", "src/i.ts"], + ["src/?.ts", "src/SSS.ts"], + ["src/???.ts", "src/ß.ts"], + ["src/S?.ts", "src/Σ.ts"], + ["src/[S]S.ts", "src/S.ts"], + ["src/[!ß].ts", "src/SS.ts"], + ["src/[!Σ].ts", "src/Σ.ts"], + ]) { + assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, false, `${left} excludes ${right}`); + } +}); +test("glob matching consumes code points, not UTF-16 code units", () => { + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/?.ts"], + right: ["src/\u{1f600}.ts"], + }).overlap, true); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/[\u{1f600}].ts"], + right: ["src/\u{1f600}.ts"], + }).overlap, true); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/[\u{10000}-\u{10002}].ts"], + right: ["src/\u{10001}.ts"], + }).overlap, true); +}); test("scope comparison and matching share literal right-bracket character classes", () => { for (const [scope, target] of [ ["src/[]/./]x", "src/.x"],