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..b70704f594 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,192 @@ 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; +/** 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; +} + +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] === "-") { - if (body[index] <= value && value <= body[index + 2]) matches = true; + ranges.push([body[index], body[index + 2]]); index += 2; - } else if (body[index] === value) { - matches = true; + } else { + literals.push(body[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); + } + 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). + * 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); + 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 { end, matches: negated ? !matches : matches }; + return false; } -function fnmatchcase(value: string, pattern: string): boolean { +/** + * 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 lengths; +} + +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]); - result = characterClass === null - ? value[valueIndex] === "[" && match(valueIndex + 1, patternIndex + 1) - : characterClass.matches && match(valueIndex + 1, characterClass.end + 1); } 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 < 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 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.includes(1) && 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) { + 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; @@ -201,7 +341,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; } @@ -209,24 +351,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 = 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..55347c0299 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,75 @@ 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"), +]) +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"), ("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 +177,60 @@ 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"), +]) +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..2880ae4901 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -270,6 +270,69 @@ 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"], + ]) { + 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"], + ]) { + 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 [ ["src/[]/./]x", "src/.x"],