From b0811d91ed824858a1e3ce9fe9a5b0ce239d7aac Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Sat, 24 Oct 2026 17:44:02 +0800 Subject: [PATCH 1/8] fix(leases): treat case aliases as overlapping write scopes Hard-lease scope comparison was case-sensitive even though the contract requires host/path case aliases to remain overlapping. Two agents could therefore acquire active exclusive leases for one physical file on default case-insensitive Windows and macOS filesystems. Case-fold only admission matching, including literals, prefixes and glob character classes, while leaving retained scopes and request identity in their original spelling. Signed-off-by: JasonBuildAI --- .../work_items/task_lease_acquire_decision.ts | 40 +++++++++++-------- .../test_canonical_lease_scopes.py | 16 ++++++++ .../task_lease_workspace.test.ts | 20 ++++++++++ 3 files changed, 60 insertions(+), 16 deletions(-) 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..0996d9efe1 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -144,25 +144,30 @@ function classMatch( pattern: string, start: number, value: string, + foldCase: boolean, ): { 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); + const foldedValue = foldCase ? value.toLowerCase() : value; 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; + if ((body[index] <= value && value <= body[index + 2]) || + (foldCase && body[index].toLowerCase() <= foldedValue && + foldedValue <= body[index + 2].toLowerCase())) matches = true; index += 2; - } else if (body[index] === value) { + } else if (body[index] === value || + (foldCase && body[index].toLowerCase() === foldedValue)) { matches = true; } } return { end, matches: negated ? !matches : matches }; } -function fnmatchcase(value: string, pattern: string): boolean { +function fnmatchcase(value: string, pattern: string, foldCase = false): boolean { const memo = new Map(); const match = (valueIndex: number, patternIndex: number): boolean => { const key = `${valueIndex}:${patternIndex}`; @@ -179,12 +184,13 @@ function fnmatchcase(value: string, pattern: string): boolean { } else if (pattern[patternIndex] === "?") { result = match(valueIndex + 1, patternIndex + 1); } else if (pattern[patternIndex] === "[") { - const characterClass = classMatch(pattern, patternIndex, value[valueIndex]); + const characterClass = classMatch(pattern, patternIndex, value[valueIndex], foldCase); result = characterClass === null ? value[valueIndex] === "[" && match(valueIndex + 1, patternIndex + 1) : characterClass.matches && match(valueIndex + 1, characterClass.end + 1); } else { - result = value[valueIndex] === pattern[patternIndex] && + result = (value[valueIndex] === pattern[patternIndex] || + (foldCase && value[valueIndex].toLowerCase() === pattern[patternIndex].toLowerCase())) && match(valueIndex + 1, patternIndex + 1); } memo.set(key, result); @@ -201,7 +207,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 || + first.toLowerCase() === second.toLowerCase(); + if (equal(left, right)) return true; if (["*", "**", "./"].includes(left) || ["*", "**", "./"].includes(right)) { return true; } @@ -209,24 +217,24 @@ function scopePairOverlaps(left: string, right: string): boolean { const rightGlob = ["*", "?", "["].some((token) => right.includes(token)); if (leftGlob && !rightGlob) { const prefix = scopeLiteralPrefix(left); - return fnmatchcase(right, left) || - (prefix.endsWith("/") && right.replace(/\/$/u, "") === prefix.replace(/\/$/u, "")); + return fnmatchcase(right, left, true) || + (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, "")); + return fnmatchcase(left, right, true) || + (prefix.endsWith("/") && equal(left.replace(/\/$/u, ""), prefix.replace(/\/$/u, ""))); } if (leftGlob && rightGlob) { - const leftPrefix = scopeLiteralPrefix(left); - const rightPrefix = scopeLiteralPrefix(right); + const leftPrefix = scopeLiteralPrefix(left).toLowerCase(); + const rightPrefix = scopeLiteralPrefix(right).toLowerCase(); 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 = left.replace(/\/$/u, "").toLowerCase(); + const rightRoot = right.replace(/\/$/u, "").toLowerCase(); + return (left.endsWith("/") && right.toLowerCase().startsWith(`${leftRoot}/`)) || + (right.endsWith("/") && left.toLowerCase().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..ffa0f46dde 100644 --- a/tests/control_plane/test_canonical_lease_scopes.py +++ b/tests/control_plane/test_canonical_lease_scopes.py @@ -98,6 +98,22 @@ 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("scope,target", [ ("src/[]/./]x", "src/.x"), ("src/[]/./]x", "src/]x"), diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index 741c7dccbc..bb043cceb7 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -270,6 +270,26 @@ 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/Main.ts"], + right: ["docs/main.ts"], + }).overlap, false); +}); test("scope comparison and matching share literal right-bracket character classes", () => { for (const [scope, target] of [ ["src/[]/./]x", "src/.x"], From 5bbd8d7305780b2e425b2816e92a058785a7afb9 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Sat, 24 Oct 2026 18:53:08 +0800 Subject: [PATCH 2/8] fix(leases): fold case within glob character classes Folding mixed-case range endpoints could still miss case aliases such as C inside [A-b], so two exclusive leases could remain active. Test the original character and its single-code-unit case variants against the unmodified class instead. Signed-off-by: JasonBuildAI --- .../work_items/task_lease_acquire_decision.ts | 27 +++++++++++-------- .../test_canonical_lease_scopes.py | 11 ++++++++ .../task_lease_workspace.test.ts | 16 +++++++++++ 3 files changed, 43 insertions(+), 11 deletions(-) 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 0996d9efe1..3ea689bcbc 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -151,19 +151,24 @@ function classMatch( let body = pattern.slice(start + 1, end); const negated = body.startsWith("!"); if (negated) body = body.slice(1); - const foldedValue = foldCase ? value.toLowerCase() : value; - 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]) || - (foldCase && body[index].toLowerCase() <= foldedValue && - foldedValue <= body[index + 2].toLowerCase())) matches = true; - index += 2; - } else if (body[index] === value || - (foldCase && body[index].toLowerCase() === foldedValue)) { - matches = true; + const contains = (candidate: string) => { + for (let index = 0; index < body.length; index += 1) { + if (index + 2 < body.length && body[index + 1] === "-") { + if (body[index] <= candidate && candidate <= body[index + 2]) return true; + index += 2; + } else if (body[index] === candidate) { + return true; + } + } + return false; + }; + const candidates = new Set([value]); + if (foldCase) { + for (const candidate of [value.toLowerCase(), value.toUpperCase()]) { + if (candidate.length === 1) candidates.add(candidate); } } + const matches = [...candidates].some(contains); return { end, matches: negated ? !matches : matches }; } diff --git a/tests/control_plane/test_canonical_lease_scopes.py b/tests/control_plane/test_canonical_lease_scopes.py index ffa0f46dde..283278872c 100644 --- a/tests/control_plane/test_canonical_lease_scopes.py +++ b/tests/control_plane/test_canonical_lease_scopes.py @@ -118,6 +118,7 @@ def test_case_aliases_remain_exclusive_write_scopes(scope_goal): ("src/[]/./]x", "src/.x"), ("src/[]/./]x", "src/]x"), ("src/[!]/./]x", "src/ax"), + ("src/[A-b]x", "src/cx"), ]) def test_glob_class_members_keep_exclusive_scope_coverage(scope_goal, scope, target): cli, current = scope_goal @@ -128,3 +129,13 @@ 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 + + +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"] diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index bb043cceb7..b0b7145bf0 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -285,6 +285,22 @@ test("scope comparison folds case aliases without rewriting retained or request 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"], From c5c7fd7745a01691135af0eeb3b4a24dee9d366b Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Mon, 26 Oct 2026 17:01:47 +0800 Subject: [PATCH 3/8] fix(leases): fold Unicode case aliases in write scopes Signed-off-by: JasonBuildAI --- .../work_items/task_lease_acquire_decision.ts | 131 ++++++++++++------ .../test_canonical_lease_scopes.py | 48 ++++++- .../task_lease_workspace.test.ts | 13 ++ 3 files changed, 151 insertions(+), 41 deletions(-) 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 3ea689bcbc..1c808a740b 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -140,17 +140,56 @@ function optionalInteger(value: unknown, label: string): number | null { return value; } -function classMatch( - pattern: string, - start: number, - value: string, - foldCase: boolean, -): { 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); +/** Host case aliases include Unicode folds whose length changes, such as ß -> ss. */ +function caseFold(value: string): string { + return value.toUpperCase().toLowerCase(); +} + +type GlobToken = + | {kind: "literal"; literal: string; 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", 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; +} + +function classMatch(body: string, value: string, foldCase: boolean): boolean { const contains = (candidate: string) => { for (let index = 0; index < body.length; index += 1) { if (index + 2 < body.length && body[index + 1] === "-") { @@ -164,39 +203,51 @@ function classMatch( }; const candidates = new Set([value]); if (foldCase) { - for (const candidate of [value.toLowerCase(), value.toUpperCase()]) { - if (candidate.length === 1) candidates.add(candidate); + for (const candidate of [value.toLowerCase(), value.toUpperCase(), caseFold(value)]) { + if ([...candidate].length === 1) candidates.add(candidate); } } - const matches = [...candidates].some(contains); - return { end, matches: negated ? !matches : matches }; + return [...candidates].some(contains); } function fnmatchcase(value: string, pattern: string, foldCase = false): boolean { + 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) { + if (tokenIndex === tokens.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], foldCase); - result = characterClass === null - ? value[valueIndex] === "[" && match(valueIndex + 1, patternIndex + 1) - : characterClass.matches && match(valueIndex + 1, characterClass.end + 1); } else { - result = (value[valueIndex] === pattern[patternIndex] || - (foldCase && value[valueIndex].toLowerCase() === pattern[patternIndex].toLowerCase())) && - match(valueIndex + 1, patternIndex + 1); + const token = tokens[tokenIndex]; + if (token.kind === "star") { + result = match(valueIndex, tokenIndex + 1) || + (valueIndex < value.length && match(valueIndex + 1, tokenIndex)); + } else if (valueIndex === value.length) { + result = false; + } else if (token.kind === "question") { + result = match(valueIndex + 1, tokenIndex + 1); + } else if (token.kind === "class") { + const matches = classMatch(token.body, value[valueIndex], foldCase); + result = (token.negated ? !matches : matches) && + match(valueIndex + 1, tokenIndex + 1); + } else { + result = false; + for (let end = valueIndex + 1; end <= value.length; end += 1) { + const candidate = value.slice(valueIndex, end); + const folded = caseFold(candidate); + if (foldCase ? folded.length > token.folded.length : candidate.length > token.literal.length) { + break; + } + if ((foldCase ? folded === token.folded : candidate === token.literal) && + match(end, tokenIndex + 1)) { + result = true; + break; + } + } + } } memo.set(key, result); return result; @@ -213,7 +264,7 @@ function scopeLiteralPrefix(scope: string): string { function scopePairOverlaps(left: string, right: string): boolean { const equal = (first: string, second: string) => first === second || - first.toLowerCase() === second.toLowerCase(); + caseFold(first) === caseFold(second); if (equal(left, right)) return true; if (["*", "**", "./"].includes(left) || ["*", "**", "./"].includes(right)) { return true; @@ -231,15 +282,15 @@ function scopePairOverlaps(left: string, right: string): boolean { (prefix.endsWith("/") && equal(left.replace(/\/$/u, ""), prefix.replace(/\/$/u, ""))); } if (leftGlob && rightGlob) { - const leftPrefix = scopeLiteralPrefix(left).toLowerCase(); - const rightPrefix = scopeLiteralPrefix(right).toLowerCase(); + const leftPrefix = caseFold(scopeLiteralPrefix(left)); + const rightPrefix = caseFold(scopeLiteralPrefix(right)); return !leftPrefix || !rightPrefix || leftPrefix.startsWith(rightPrefix) || rightPrefix.startsWith(leftPrefix); } - const leftRoot = left.replace(/\/$/u, "").toLowerCase(); - const rightRoot = right.replace(/\/$/u, "").toLowerCase(); - return (left.endsWith("/") && right.toLowerCase().startsWith(`${leftRoot}/`)) || - (right.endsWith("/") && left.toLowerCase().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 283278872c..7fb8c45e58 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) @@ -114,6 +114,52 @@ def test_case_aliases_remain_exclusive_write_scopes(scope_goal): assert current() == before +@pytest.mark.parametrize("first_scope,second_scope", [ + ("src/Σ.ts", "src/ς.ts"), + ("src/ß.ts", "src/SS.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"), +]) +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"), diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index b0b7145bf0..0483749543 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -305,6 +305,19 @@ test("scope comparison folds case aliases without rewriting retained or request 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"], + ]) { + assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, true, `${left} overlaps ${right}`); + } + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/[!Σ]x"], + right: ["src/ςx"], + }).overlap, false); }); test("scope comparison and matching share literal right-bracket character classes", () => { for (const [scope, target] of [ From 76d0ada1b45f33e1a8a1314d6b4771cc270762b6 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Mon, 26 Oct 2026 20:42:21 +0800 Subject: [PATCH 4/8] fix(leases): fold Unicode case aliases inside glob character classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Character-class membership compared the input against the class body one way only, so a class member that folds to the compared text (a class member ς against Σ) missed, and a member whose fold changes length ([ß] against SS) could never match because the class branch consumed exactly one unit. Membership is now a symmetric fold equivalence over both literals and single-code-point range variants, negation applies after that union, and a class may consume the number of units its fold needs. Original range intervals, negated semantics, stored spelling and request identity are unchanged. Signed-off-by: JasonBuildAI --- .../work_items/task_lease_acquire_decision.ts | 83 ++++++++++++++----- .../test_canonical_lease_scopes.py | 49 +++++++++++ .../task_lease_workspace.test.ts | 13 ++- 3 files changed, 122 insertions(+), 23 deletions(-) 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 1c808a740b..8d830a17bd 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -189,25 +189,68 @@ function globTokens(pattern: string): GlobToken[] { return tokens; } -function classMatch(body: string, value: string, foldCase: boolean): boolean { - const contains = (candidate: string) => { - for (let index = 0; index < body.length; index += 1) { - if (index + 2 < body.length && body[index + 1] === "-") { - if (body[index] <= candidate && candidate <= body[index + 2]) return true; - index += 2; - } else if (body[index] === candidate) { - return true; - } +type CharacterClass = { + literals: readonly string[]; + ranges: readonly (readonly [string, string])[]; +}; + +function characterClass(body: string): CharacterClass { + const literals: string[] = []; + const ranges: Array = []; + for (let index = 0; index < body.length; index += 1) { + if (index + 2 < body.length && body[index + 1] === "-") { + ranges.push([body[index], body[index + 2]]); + index += 2; + } else { + literals.push(body[index]); } - return false; - }; - const candidates = new Set([value]); - if (foldCase) { - for (const candidate of [value.toLowerCase(), value.toUpperCase(), caseFold(value)]) { - if ([...candidate].length === 1) candidates.add(candidate); + } + return {literals, ranges}; +} + +/** Single-character case variants, so a range keeps its original interval. */ +function singleCharacterVariants(value: string): string[] { + const variants = new Set(); + for (const candidate of [value, value.toLowerCase(), value.toUpperCase(), caseFold(value)]) { + if ([...candidate].length === 1) variants.add(candidate); + } + return [...variants]; +} + +/** + * Class membership is a symmetric fold equivalence: a member and the compared text + * match when their canonical fold is equal. That covers non-reciprocal Unicode + * aliases (a class member ς against Σ) and folds that change length (ß against SS). + * Ranges keep their original interval and only ever consume one unit. + */ +function classMemberMatches(body: string, candidate: string, foldCase: boolean): boolean { + const {literals, ranges} = characterClass(body); + const folded = foldCase ? caseFold(candidate) : candidate; + for (const literal of literals) { + if (literal === candidate || (foldCase && caseFold(literal) === folded)) return true; + } + if ([...candidate].length !== 1) return false; + for (const [low, high] of ranges) { + for (const variant of (foldCase ? singleCharacterVariants(candidate) : [candidate])) { + if (low <= variant && variant <= high) return true; + } + } + return false; +} + +/** + * A class consumes one unit by default. Case folding may consume several when the + * compared text folds to the same string as a member (ß/SS, ffi/FFI). + */ +function classMatchLengths(body: string, value: string, start: number, foldCase: boolean): number[] { + const lengths: number[] = []; + const maximum = foldCase ? 3 : 1; + for (let length = 1; length <= maximum && start + length <= value.length; length += 1) { + if (classMemberMatches(body, value.slice(start, start + length), foldCase)) { + lengths.push(length); } } - return [...candidates].some(contains); + return lengths; } function fnmatchcase(value: string, pattern: string, foldCase = false): boolean { @@ -230,9 +273,11 @@ function fnmatchcase(value: string, pattern: string, foldCase = false): boolean } else if (token.kind === "question") { result = match(valueIndex + 1, tokenIndex + 1); } else if (token.kind === "class") { - const matches = classMatch(token.body, value[valueIndex], foldCase); - result = (token.negated ? !matches : matches) && - match(valueIndex + 1, tokenIndex + 1); + // Negation applies after the union of every fold-equivalent candidate. + const lengths = classMatchLengths(token.body, value, valueIndex, foldCase); + result = token.negated + ? lengths.length === 0 && match(valueIndex + 1, tokenIndex + 1) + : lengths.some((length) => match(valueIndex + length, tokenIndex + 1)); } else { result = false; for (let end = valueIndex + 1; end <= value.length; end += 1) { diff --git a/tests/control_plane/test_canonical_lease_scopes.py b/tests/control_plane/test_canonical_lease_scopes.py index 7fb8c45e58..55347c0299 100644 --- a/tests/control_plane/test_canonical_lease_scopes.py +++ b/tests/control_plane/test_canonical_lease_scopes.py @@ -165,6 +165,8 @@ def add_todo(label, scope): ("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 @@ -177,6 +179,38 @@ def test_glob_class_members_keep_exclusive_scope_coverage(scope_goal, scope, tar 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", @@ -185,3 +219,18 @@ def test_case_folded_negated_glob_class_excludes_case_variant(scope_goal): 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"), +]) +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 0483749543..a1f7dfeb92 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -311,13 +311,18 @@ test("scope comparison folds case aliases without rewriting retained or request ["src/Σ/", "src/ς/file.ts"], ["src/ß/*.ts", "src/SS/file.ts"], ["src/[Σ]x", "src/ςx"], + ["src/[ς]x", "src/Σx"], + ["src/[ß]x", "src/SSx"], ]) { assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, true, `${left} overlaps ${right}`); } - assert.equal(evaluateTaskLeaseWriteScopesOverlap({ - left: ["src/[!Σ]x"], - right: ["src/ςx"], - }).overlap, false); + for (const [left, right] of [ + ["src/[!Σ]x", "src/ςx"], + ["src/[!ς]x", "src/Σx"], + ["src/[!ß]x", "src/SSx"], + ]) { + assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, false, `${left} excludes ${right}`); + } }); test("scope comparison and matching share literal right-bracket character classes", () => { for (const [scope, target] of [ From c458bc7acf92aae860ee5ee2e8332c6a3b8bdf45 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Tue, 27 Oct 2026 15:46:43 +0800 Subject: [PATCH 5/8] fix(leases): fold one-element ranges and keep negated class continuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A class written as a one-element range ([ς-ς], [ß-ß]) is a literal member, so it must accept the same canonical-fold aliases as a literal: Σ/ς and SS/ß. The range branch only compared single-character variants of the candidate, so the other spelling still acquired a second exclusive lease. Negation also treated any fold-equivalent multi-character candidate as a class match, so [!ß] consumed SS and left nothing for the following literal S. A class still consumes one character; only single-character membership decides negation, and longer folds remain available to the next token. Signed-off-by: JasonBuildAI --- .../work_items/task_lease_acquire_decision.ts | 14 +++++++++++--- .../control_plane_ts/task_lease_workspace.test.ts | 5 +++++ 2 files changed, 16 insertions(+), 3 deletions(-) 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 8d830a17bd..578e4c56c3 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -229,8 +229,14 @@ function classMemberMatches(body: string, candidate: string, foldCase: boolean): for (const literal of literals) { if (literal === candidate || (foldCase && caseFold(literal) === folded)) return true; } - if ([...candidate].length !== 1) return false; for (const [low, high] of ranges) { + // A one-element range is a literal written in range syntax, so it has to + // accept the same multi-character folds (ß/SS) a literal member does. + if (low === high) { + if (low === candidate || (foldCase && caseFold(low) === folded)) return true; + continue; + } + if ([...candidate].length !== 1) continue; for (const variant of (foldCase ? singleCharacterVariants(candidate) : [candidate])) { if (low <= variant && variant <= high) return true; } @@ -273,10 +279,12 @@ function fnmatchcase(value: string, pattern: string, foldCase = false): boolean } else if (token.kind === "question") { result = match(valueIndex + 1, tokenIndex + 1); } else if (token.kind === "class") { - // Negation applies after the union of every fold-equivalent candidate. const lengths = classMatchLengths(token.body, value, valueIndex, foldCase); + // Negation only excludes a single character: a longer fold candidate + // (SS for ß) is two ordinary characters to a class, so it must remain + // available for the following tokens. result = token.negated - ? lengths.length === 0 && match(valueIndex + 1, tokenIndex + 1) + ? !lengths.includes(1) && match(valueIndex + 1, tokenIndex + 1) : lengths.some((length) => match(valueIndex + length, tokenIndex + 1)); } else { result = false; diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index a1f7dfeb92..ca9b4c140f 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -313,6 +313,9 @@ test("scope comparison folds case aliases without rewriting retained or request ["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"], ]) { assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, true, `${left} overlaps ${right}`); } @@ -320,6 +323,8 @@ test("scope comparison folds case aliases without rewriting retained or request ["src/[!Σ]x", "src/ςx"], ["src/[!ς]x", "src/Σx"], ["src/[!ß]x", "src/SSx"], + ["src/[!ß]S.ts", "src/S.ts"], + ["src/[ß-ß].ts", "src/S.ts"], ]) { assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, false, `${left} excludes ${right}`); } From 3671dd009f3fbba53fe5eae52d7eac6e9e4e7cf8 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Tue, 27 Oct 2026 20:48:22 +0800 Subject: [PATCH 6/8] fix(leases): fold every member of a Unicode range in write scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class membership compared only the candidate's own case variants against a range, so a general interval missed both alias shapes it had to cover. `[ρ-ς]` did not cover `Σ`: the variants of Σ are Σ and σ, and neither is a member, although Σ and the member ς are the same file. `[ß-à]` dropped `SS` because a multi-character candidate was skipped before the range was checked. Walk the range's code points and compare each member's canonical fold with the candidate's. That keeps the original interval and gives both shapes the same symmetric comparison single-character members already had, so `[ρ-ς]` covers `Σ` and `[ß-à]` consumes `SS` as ß. A one-element range is covered by the same path. Negated classes keep excluding exactly one consumed character, so `[!ß]S.ts` still continues into `SS.ts`, while `[!ρ-ς]` now excludes `Σ` instead of admitting it as an uncovered path. An interval too wide to walk falls back to the candidate's own case variants. Signed-off-by: JasonBuildAI --- .../work_items/task_lease_acquire_decision.ts | 41 +++++++++++++++---- .../task_lease_workspace.test.ts | 4 ++ 2 files changed, 37 insertions(+), 8 deletions(-) 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 578e4c56c3..b70704f594 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -217,11 +217,28 @@ function singleCharacterVariants(value: string): string[] { return [...variants]; } +/** A range wider than this is compared by case variants instead of walked. */ +const RANGE_MEMBER_LIMIT = 4096; + +/** Code points a range covers, or null when the interval is too wide to walk. */ +function rangeMembers(low: string, high: string): string[] | null { + const start = low.codePointAt(0); + const end = high.codePointAt(0); + if (start === undefined || end === undefined || start > end) return []; + if (end - start + 1 > RANGE_MEMBER_LIMIT) return null; + const members: string[] = []; + for (let code = start; code <= end; code += 1) { + members.push(String.fromCodePoint(code)); + } + return members; +} + /** * Class membership is a symmetric fold equivalence: a member and the compared text * match when their canonical fold is equal. That covers non-reciprocal Unicode * aliases (a class member ς against Σ) and folds that change length (ß against SS). - * Ranges keep their original interval and only ever consume one unit. + * Every range member is compared by its fold, so a general interval keeps its + * original bounds without losing either kind of alias. */ function classMemberMatches(body: string, candidate: string, foldCase: boolean): boolean { const {literals, ranges} = characterClass(body); @@ -230,15 +247,23 @@ function classMemberMatches(body: string, candidate: string, foldCase: boolean): if (literal === candidate || (foldCase && caseFold(literal) === folded)) return true; } for (const [low, high] of ranges) { - // A one-element range is a literal written in range syntax, so it has to - // accept the same multi-character folds (ß/SS) a literal member does. - if (low === high) { - if (low === candidate || (foldCase && caseFold(low) === folded)) return true; + if (!foldCase) { + if ([...candidate].length === 1 && low <= candidate && candidate <= high) return true; + continue; + } + const members = rangeMembers(low, high); + if (members === null) { + // Walking a very wide interval on every comparison would dominate the + // decision, so fall back to the candidate's own case variants. Ordinary + // scopes use short intervals, which take the exact path below. + if ([...candidate].length !== 1) continue; + for (const variant of singleCharacterVariants(candidate)) { + if (low <= variant && variant <= high) return true; + } continue; } - if ([...candidate].length !== 1) continue; - for (const variant of (foldCase ? singleCharacterVariants(candidate) : [candidate])) { - if (low <= variant && variant <= high) return true; + for (const member of members) { + if (caseFold(member) === folded) return true; } } return false; diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index ca9b4c140f..2880ae4901 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -316,6 +316,9 @@ test("scope comparison folds case aliases without rewriting retained or request ["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"], ]) { assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, true, `${left} overlaps ${right}`); } @@ -325,6 +328,7 @@ test("scope comparison folds case aliases without rewriting retained or request ["src/[!ß]x", "src/SSx"], ["src/[!ß]S.ts", "src/S.ts"], ["src/[ß-ß].ts", "src/S.ts"], + ["src/[!ρ-ς].ts", "src/Σ.ts"], ]) { assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, false, `${left} excludes ${right}`); } From e2b1a1045605d5c444ec287cd9aadb4bdf51cf3a Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Wed, 28 Oct 2026 02:31:48 +0800 Subject: [PATCH 7/8] fix(leases): compare fold classes so wide ranges stay exclusive A class member and the compared text now match when they share a canonical case-fold value, and equivalence is decided by looking up the compared text's fold class instead of enumerating interval members. That removes the RANGE_MEMBER_LIMIT shortcut, which above 4096 members only folded the candidate's own case variants, so a range from U+00DF or from the Kelvin sign still admitted a second exclusive lease for `src/SS.ts` or `src/K.ts` through File and SQLite acquire and atomic Todo claim. Fold classes are small and exact at every interval width: the inverse map is built once, lazily, and then reused. Single-code-point folding is iterated to a stable value so it stays an equivalence relation (U+1E9E and U+00DF now compare equal), and matching consumes code points rather than UTF-16 units while keeping the original range bounds, negation order, retained scope spelling and request identity unchanged. Signed-off-by: JasonBuildAI --- .../work_items/task_lease_acquire_decision.ts | 209 +++++++++++------- .../test_canonical_lease_scopes.py | 9 + .../task_lease_workspace.test.ts | 21 ++ 3 files changed, 157 insertions(+), 82 deletions(-) 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 b70704f594..6f61112c78 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -140,13 +140,44 @@ function optionalInteger(value: unknown, label: string): number | null { return value; } -/** Host case aliases include Unicode folds whose length changes, such as ß -> ss. */ +/** + * 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 { - return value.toUpperCase().toLowerCase(); + 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"; literal: string; folded: string} + | {kind: "literal"; folded: string} | {kind: "star"} | {kind: "question"} | {kind: "class"; body: string; negated: boolean}; @@ -156,7 +187,7 @@ function globTokens(pattern: string): GlobToken[] { let literal = ""; const flushLiteral = () => { if (literal.length === 0) return; - tokens.push({kind: "literal", literal, folded: caseFold(literal)}); + tokens.push({kind: "literal", folded: caseFold(literal)}); literal = ""; }; for (let index = 0; index < pattern.length; index += 1) { @@ -191,100 +222,118 @@ function globTokens(pattern: string): GlobToken[] { type CharacterClass = { literals: readonly string[]; - ranges: readonly (readonly [string, 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 < body.length; index += 1) { - if (index + 2 < body.length && body[index + 1] === "-") { - ranges.push([body[index], body[index + 2]]); + 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 { - literals.push(body[index]); + literals.push(members[index]); } } return {literals, ranges}; } -/** Single-character case variants, so a range keeps its original interval. */ -function singleCharacterVariants(value: string): string[] { - const variants = new Set(); - for (const candidate of [value, value.toLowerCase(), value.toUpperCase(), caseFold(value)]) { - if ([...candidate].length === 1) variants.add(candidate); +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); + } } - return [...variants]; + foldPreimages = preimages; + return preimages; } -/** A range wider than this is compared by case variants instead of walked. */ -const RANGE_MEMBER_LIMIT = 4096; +/** Longest fold one class member expands to; derived rather than assumed. */ +let classConsumptionLimit = 0; -/** Code points a range covers, or null when the interval is too wide to walk. */ -function rangeMembers(low: string, high: string): string[] | null { - const start = low.codePointAt(0); - const end = high.codePointAt(0); - if (start === undefined || end === undefined || start > end) return []; - if (end - start + 1 > RANGE_MEMBER_LIMIT) return null; - const members: string[] = []; - for (let code = start; code <= end; code += 1) { - members.push(String.fromCodePoint(code)); +function maximumClassConsumption(): number { + if (classConsumptionLimit === 0) { + let limit = 1; + for (const folded of foldPreimagesByFold().keys()) { + limit = Math.max(limit, [...folded].length); + } + classConsumptionLimit = limit; } - return members; + return classConsumptionLimit; } /** - * Class membership is a symmetric fold equivalence: a member and the compared text - * match when their canonical fold is equal. That covers non-reciprocal Unicode - * aliases (a class member ς against Σ) and folds that change length (ß against SS). - * Every range member is compared by its fold, so a general interval keeps its - * original bounds without losing either kind of alias. + * 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, foldCase: boolean): boolean { - const {literals, ranges} = characterClass(body); - const folded = foldCase ? caseFold(candidate) : candidate; - for (const literal of literals) { - if (literal === candidate || (foldCase && caseFold(literal) === folded)) return true; - } - for (const [low, high] of ranges) { - if (!foldCase) { - if ([...candidate].length === 1 && low <= candidate && candidate <= high) return true; - continue; - } - const members = rangeMembers(low, high); - if (members === null) { - // Walking a very wide interval on every comparison would dominate the - // decision, so fall back to the candidate's own case variants. Ordinary - // scopes use short intervals, which take the exact path below. - if ([...candidate].length !== 1) continue; - for (const variant of singleCharacterVariants(candidate)) { - if (low <= variant && variant <= high) return true; - } - continue; - } - for (const member of members) { - if (caseFold(member) === folded) return true; - } - } - return false; +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 unit by default. Case folding may consume several when the - * compared text folds to the same string as a member (ß/SS, ffi/FFI). + * 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, value: string, start: number, foldCase: boolean): number[] { +function classMatchLengths(body: string, symbols: readonly string[], start: number): number[] { const lengths: number[] = []; - const maximum = foldCase ? 3 : 1; - for (let length = 1; length <= maximum && start + length <= value.length; length += 1) { - if (classMemberMatches(body, value.slice(start, start + length), foldCase)) { + for (let length = 1; length <= maximumClassConsumption() && start + length <= symbols.length; length += 1) { + if (classMemberMatches(body, symbols.slice(start, start + length).join(""))) { lengths.push(length); } } return lengths; } -function fnmatchcase(value: string, pattern: string, foldCase = false): boolean { +/** Comparison always folds host case aliases; symbol indexes stay on code points. */ +function fnmatchcase(value: string, pattern: string): boolean { + const symbols = [...value]; const tokens = globTokens(pattern); const memo = new Map(); const match = (valueIndex: number, tokenIndex: number): boolean => { @@ -293,19 +342,19 @@ function fnmatchcase(value: string, pattern: string, foldCase = false): boolean if (cached !== undefined) return cached; let result: boolean; if (tokenIndex === tokens.length) { - result = valueIndex === value.length; + result = valueIndex === symbols.length; } else { const token = tokens[tokenIndex]; if (token.kind === "star") { result = match(valueIndex, tokenIndex + 1) || - (valueIndex < value.length && match(valueIndex + 1, tokenIndex)); - } else if (valueIndex === value.length) { + (valueIndex < symbols.length && match(valueIndex + 1, tokenIndex)); + } else if (valueIndex === symbols.length) { result = false; } else if (token.kind === "question") { result = match(valueIndex + 1, tokenIndex + 1); } else if (token.kind === "class") { - const lengths = classMatchLengths(token.body, value, valueIndex, foldCase); - // Negation only excludes a single character: a longer fold candidate + const lengths = classMatchLengths(token.body, symbols, valueIndex); + // Negation only excludes a single code point: a longer fold candidate // (SS for ß) is two ordinary characters to a class, so it must remain // available for the following tokens. result = token.negated @@ -313,14 +362,10 @@ function fnmatchcase(value: string, pattern: string, foldCase = false): boolean : lengths.some((length) => match(valueIndex + length, tokenIndex + 1)); } else { result = false; - for (let end = valueIndex + 1; end <= value.length; end += 1) { - const candidate = value.slice(valueIndex, end); - const folded = caseFold(candidate); - if (foldCase ? folded.length > token.folded.length : candidate.length > token.literal.length) { - break; - } - if ((foldCase ? folded === token.folded : candidate === token.literal) && - match(end, tokenIndex + 1)) { + for (let end = valueIndex + 1; end <= symbols.length; end += 1) { + const folded = caseFold(symbols.slice(valueIndex, end).join("")); + if (folded.length > token.folded.length) break; + if (folded === token.folded && match(end, tokenIndex + 1)) { result = true; break; } @@ -351,12 +396,12 @@ function scopePairOverlaps(left: string, right: string): boolean { const rightGlob = ["*", "?", "["].some((token) => right.includes(token)); if (leftGlob && !rightGlob) { const prefix = scopeLiteralPrefix(left); - return fnmatchcase(right, left, true) || + return fnmatchcase(right, left) || (prefix.endsWith("/") && equal(right.replace(/\/$/u, ""), prefix.replace(/\/$/u, ""))); } if (rightGlob && !leftGlob) { const prefix = scopeLiteralPrefix(right); - return fnmatchcase(left, right, true) || + return fnmatchcase(left, right) || (prefix.endsWith("/") && equal(left.replace(/\/$/u, ""), prefix.replace(/\/$/u, ""))); } if (leftGlob && rightGlob) { diff --git a/tests/control_plane/test_canonical_lease_scopes.py b/tests/control_plane/test_canonical_lease_scopes.py index 55347c0299..0858e85219 100644 --- a/tests/control_plane/test_canonical_lease_scopes.py +++ b/tests/control_plane/test_canonical_lease_scopes.py @@ -117,6 +117,10 @@ def test_case_aliases_remain_exclusive_write_scopes(scope_goal): @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"), ]) def test_unicode_case_aliases_remain_exclusive_write_scopes(scope_goal, first_scope, second_scope): cli, current = scope_goal @@ -134,6 +138,9 @@ def test_unicode_case_aliases_remain_exclusive_write_scopes(scope_goal, first_sc @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"), ]) def test_unicode_case_aliases_conflict_at_atomic_todo_claim(scope_goal, first_scope, second_scope): cli, current = scope_goal @@ -225,6 +232,8 @@ def test_case_folded_negated_glob_class_excludes_case_variant(scope_goal): ("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"), ]) def test_case_folded_negated_glob_class_excludes_unicode_alias(scope_goal, scope, target): cli, _ = scope_goal diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index 2880ae4901..5f0eadd99d 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -319,6 +319,11 @@ test("scope comparison folds case aliases without rewriting retained or request ["src/[ρ-ς].ts", "src/Σ.ts"], ["src/[ρ-ς].ts", "src/σ.ts"], ["src/[ß-à].ts", "src/SS.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}`); } @@ -329,10 +334,26 @@ test("scope comparison folds case aliases without rewriting retained or request ["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"], ]) { 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"], From ffe79c38a65d32ec7918f6a3b06286372b77a935 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Wed, 28 Oct 2026 03:53:22 +0800 Subject: [PATCH 8/8] fix(leases): match glob tokens across folded aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host character can fold to several code points (ß to SS), so a glob token cannot assume one folded symbol per original character. Match against the folded path stream, allow ? to consume one character's fold expansion, and let literal and class boundaries compose across that expansion. This closes the remaining question-mark and cross-token alias bypasses without changing retained scope spelling or request identity. Signed-off-by: JasonBuildAI --- .../work_items/task_lease_acquire_decision.ts | 58 +++++++++++-------- .../test_canonical_lease_scopes.py | 22 +++++++ .../task_lease_workspace.test.ts | 14 +++++ 3 files changed, 71 insertions(+), 23 deletions(-) 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 6f61112c78..3b3df2ad7f 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -287,18 +287,18 @@ function foldPreimagesByFold(): ReadonlyMap { return preimages; } -/** Longest fold one class member expands to; derived rather than assumed. */ -let classConsumptionLimit = 0; +/** Longest fold one code point expands to; derived rather than assumed. */ +let foldConsumptionLimit = 0; -function maximumClassConsumption(): number { - if (classConsumptionLimit === 0) { +function maximumFoldConsumption(): number { + if (foldConsumptionLimit === 0) { let limit = 1; for (const folded of foldPreimagesByFold().keys()) { limit = Math.max(limit, [...folded].length); } - classConsumptionLimit = limit; + foldConsumptionLimit = limit; } - return classConsumptionLimit; + return foldConsumptionLimit; } /** @@ -323,7 +323,7 @@ function classMemberMatches(body: string, candidate: string): boolean { */ function classMatchLengths(body: string, symbols: readonly string[], start: number): number[] { const lengths: number[] = []; - for (let length = 1; length <= maximumClassConsumption() && start + length <= symbols.length; length += 1) { + for (let length = 1; length <= maximumFoldConsumption() && start + length <= symbols.length; length += 1) { if (classMemberMatches(body, symbols.slice(start, start + length).join(""))) { lengths.push(length); } @@ -331,9 +331,26 @@ function classMatchLengths(body: string, symbols: readonly string[], start: numb return lengths; } -/** Comparison always folds host case aliases; symbol indexes stay on code points. */ +/** 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 = [...value]; + const symbols = [...caseFold(value)]; const tokens = globTokens(pattern); const memo = new Map(); const match = (valueIndex: number, tokenIndex: number): boolean => { @@ -351,25 +368,20 @@ function fnmatchcase(value: string, pattern: string): boolean { } else if (valueIndex === symbols.length) { result = false; } else if (token.kind === "question") { - result = match(valueIndex + 1, tokenIndex + 1); + result = singleCharacterMatchLengths(symbols, valueIndex) + .some((length) => match(valueIndex + length, tokenIndex + 1)); } else if (token.kind === "class") { const lengths = classMatchLengths(token.body, symbols, valueIndex); - // Negation only excludes a single code point: a longer fold candidate - // (SS for ß) is two ordinary characters to a class, so it must remain - // available for the following tokens. result = token.negated - ? !lengths.includes(1) && match(valueIndex + 1, tokenIndex + 1) + ? singleCharacterMatchLengths(symbols, valueIndex).some((length) => + !lengths.includes(length) && match(valueIndex + length, tokenIndex + 1)) : lengths.some((length) => match(valueIndex + length, tokenIndex + 1)); } else { - result = false; - for (let end = valueIndex + 1; end <= symbols.length; end += 1) { - const folded = caseFold(symbols.slice(valueIndex, end).join("")); - if (folded.length > token.folded.length) break; - if (folded === token.folded && match(end, tokenIndex + 1)) { - result = true; - break; - } - } + 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); diff --git a/tests/control_plane/test_canonical_lease_scopes.py b/tests/control_plane/test_canonical_lease_scopes.py index 0858e85219..f56da30279 100644 --- a/tests/control_plane/test_canonical_lease_scopes.py +++ b/tests/control_plane/test_canonical_lease_scopes.py @@ -121,6 +121,16 @@ def test_case_aliases_remain_exclusive_write_scopes(scope_goal): ("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 @@ -141,6 +151,16 @@ def test_unicode_case_aliases_remain_exclusive_write_scopes(scope_goal, first_sc ("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 @@ -234,6 +254,8 @@ def test_case_folded_negated_glob_class_excludes_case_variant(scope_goal): ("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 diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index 5f0eadd99d..63e2186c86 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -319,6 +319,14 @@ test("scope comparison folds case aliases without rewriting retained or request ["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"], @@ -336,6 +344,12 @@ test("scope comparison folds case aliases without rewriting retained or request ["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}`); }