Skip to content

Commit 10209f8

Browse files
committed
fix(leases): fold every member of a Unicode range in write scopes
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 <jasonbuildai@gmail.com>
1 parent 09f9bf3 commit 10209f8

2 files changed

Lines changed: 37 additions & 8 deletions

File tree

‎loopx/control_plane/work_items/task_lease_acquire_decision.ts‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -217,11 +217,28 @@ function singleCharacterVariants(value: string): string[] {
217217
return [...variants];
218218
}
219219

220+
/** A range wider than this is compared by case variants instead of walked. */
221+
const RANGE_MEMBER_LIMIT = 4096;
222+
223+
/** Code points a range covers, or null when the interval is too wide to walk. */
224+
function rangeMembers(low: string, high: string): string[] | null {
225+
const start = low.codePointAt(0);
226+
const end = high.codePointAt(0);
227+
if (start === undefined || end === undefined || start > end) return [];
228+
if (end - start + 1 > RANGE_MEMBER_LIMIT) return null;
229+
const members: string[] = [];
230+
for (let code = start; code <= end; code += 1) {
231+
members.push(String.fromCodePoint(code));
232+
}
233+
return members;
234+
}
235+
220236
/**
221237
* Class membership is a symmetric fold equivalence: a member and the compared text
222238
* match when their canonical fold is equal. That covers non-reciprocal Unicode
223239
* aliases (a class member ς against Σ) and folds that change length (ß against SS).
224-
* Ranges keep their original interval and only ever consume one unit.
240+
* Every range member is compared by its fold, so a general interval keeps its
241+
* original bounds without losing either kind of alias.
225242
*/
226243
function classMemberMatches(body: string, candidate: string, foldCase: boolean): boolean {
227244
const {literals, ranges} = characterClass(body);
@@ -230,15 +247,23 @@ function classMemberMatches(body: string, candidate: string, foldCase: boolean):
230247
if (literal === candidate || (foldCase && caseFold(literal) === folded)) return true;
231248
}
232249
for (const [low, high] of ranges) {
233-
// A one-element range is a literal written in range syntax, so it has to
234-
// accept the same multi-character folds (ß/SS) a literal member does.
235-
if (low === high) {
236-
if (low === candidate || (foldCase && caseFold(low) === folded)) return true;
250+
if (!foldCase) {
251+
if ([...candidate].length === 1 && low <= candidate && candidate <= high) return true;
252+
continue;
253+
}
254+
const members = rangeMembers(low, high);
255+
if (members === null) {
256+
// Walking a very wide interval on every comparison would dominate the
257+
// decision, so fall back to the candidate's own case variants. Ordinary
258+
// scopes use short intervals, which take the exact path below.
259+
if ([...candidate].length !== 1) continue;
260+
for (const variant of singleCharacterVariants(candidate)) {
261+
if (low <= variant && variant <= high) return true;
262+
}
237263
continue;
238264
}
239-
if ([...candidate].length !== 1) continue;
240-
for (const variant of (foldCase ? singleCharacterVariants(candidate) : [candidate])) {
241-
if (low <= variant && variant <= high) return true;
265+
for (const member of members) {
266+
if (caseFold(member) === folded) return true;
242267
}
243268
}
244269
return false;

‎tests/control_plane_ts/task_lease_workspace.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,9 @@ test("scope comparison folds case aliases without rewriting retained or request
316316
["src/[ς-ς].ts", "src/Σ.ts"],
317317
["src/[ß-ß].ts", "src/SS.ts"],
318318
["src/[!ß]S.ts", "src/SS.ts"],
319+
["src/[ρ-ς].ts", "src/Σ.ts"],
320+
["src/[ρ-ς].ts", "src/σ.ts"],
321+
["src/[ß-à].ts", "src/SS.ts"],
319322
]) {
320323
assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, true, `${left} overlaps ${right}`);
321324
}
@@ -325,6 +328,7 @@ test("scope comparison folds case aliases without rewriting retained or request
325328
["src/[!ß]x", "src/SSx"],
326329
["src/[!ß]S.ts", "src/S.ts"],
327330
["src/[ß-ß].ts", "src/S.ts"],
331+
["src/[!ρ-ς].ts", "src/Σ.ts"],
328332
]) {
329333
assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, false, `${left} excludes ${right}`);
330334
}

0 commit comments

Comments
 (0)