Skip to content

Commit 0194789

Browse files
feat(naming): widen the Roblox allowed-words list to members and trailing capitals (#664)
* fix(naming): allow Roblox names that end in a capital The filter tested each name in isolation, but the failure mode is the name embedded in a longer identifier. Motor6D holds no pair of its own, so it was dropped - yet targetMotor6DPart, motor6DWeld and Motor6DConfig all error, because the trailing D lands against the next word's capital. Adds a trailing-capital arm to the predicate, which picks up Motor6D, Path2D, Path3D, RotateP and RotateV, and lets Path2D absorb Path2DControlPoint. 67 words -> 71. Word-initial position is still uncovered: matching is case-sensitive and strictCamelCase lowercases the first word, so motor6DWeld stays an error while targetMotor6DPart passes. Same limitation flawless documents for cframeGoal. Noted in the README. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(naming): scrape member names for the allowed-words list The list was built from declared type names only, on the reasoning that members are reached as property accesses and never declared. That holds for enum items but not for properties: a variable is as likely to be named after the property it holds as after a type, and no type is called ZIndex, so autoZIndex stayed an error. ZIndexBehavior was in the list, but a word has to match literally, and autoZIndex does not contain it. Scrapes interface members from roblox.d.ts and None.d.ts. Enum items stay out - Enum.TextXAlignment.Center really is only ever an access. 71 words -> 171, and ZIndex now absorbs ZIndexBehavior. Members also drag in bare capitals (Vector3.X and friends), which qualify under the trailing-capital arm. They are inert rather than dangerous - folding only lowercases a word's tail and a single capital has none, so applyAllowedWords returns the name unchanged - but nothing could ever prune them, so needsAllowing now rejects anything shorter than two characters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(naming): drop method names from the allowed-words list The member scrape took every member, so `Color3.ToHSV` put `ToHSV` in the list and `colorToHSV` became a legal variable. A property lends its spelling to whatever holds it, which is the whole reason to scrape members, but a call spells the API out at the call site, where no naming rule applies. `toHsv` is the name to want. Restricts the member pattern to data properties. All three declaration forms count as a method - `ToHSV(this: Color3): T`, `Name<T>(value: T): T` and `toHSV: (color: Color3) => T` - and the last needs the lookahead to skip its own whitespace, since a negative lookahead after `\s*` backtracks to zero width and then tests the space rather than the `(`. 171 words -> 139, all 32 of them function-only. Nothing was masking a property during pruning, so no word comes back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(deps): take the flawless 1.9.0 word-initial fix `allowedWords` matched case-sensitively, so a word never matched at the start of a name: `targetMotor6DPart` passed but `motor6DWeld` did not. flawless 1.9.0 matches the lowercased-initial form at index 0, which is the only spelling `strictCamelCase` permits there. Mirrors the change in the generator's copy of `applyAllowedWords`, which prunes the list by asking the question the rule will ask. The output is unchanged at 139 words - nothing in `@rbxts/types` was being kept only because index 0 could not match. Drops the README caveat. `pnpm gen` also picks up `flawless/no-shared-mocks`, new in the bump and not enabled here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 13e6cb9 commit 0194789

8 files changed

Lines changed: 340 additions & 37 deletions

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,9 +793,16 @@ export default isentinel({
793793
```ts
794794
const targetCFrame = new CFrame(); // ok
795795
const listLayoutUIPadding = 0; // ok
796+
const autoZIndex = 0; // ok - properties count, not just types
796797
const target_CFrame = 0; // still an error
798+
const fooXYBar = 0; // still an error - not a Roblox name
799+
const colorToHSV = 0; // still an error - methods do not count, use `colorToHsv`
797800
```
798801

802+
The generated list covers type names and data properties, not methods. A call
803+
spells the API out at the call site, where the naming rules do not reach, so
804+
`Color3.ToHSV` is no reason to let the variable holding its result skip them.
805+
799806
Pass an array to use exactly those words instead. Spread `ROBLOX_ALLOWED_WORDS`
800807
to extend the list rather than replace it:
801808

@@ -814,6 +821,11 @@ selector opts out with its own `allowedWords: []`. Only the two strict formats
814821
honour it, so it can never loosen `snake_case` or `UPPER_CASE`, and a word only
815822
matches at a hump boundary, so it cannot split an existing hump.
816823

824+
At the **start** of a `strictCamelCase` name a word also matches with its first
825+
character lowercased, since that is the only spelling the format allows there —
826+
`motor6DWeld` and `targetMotor6DPart` both pass. Everywhere else the word has to
827+
be spelled as the API spells it, so `targetmotor6DPart` is still an error.
828+
817829
#### Oxlint
818830

819831
The config can run alongside (or be replaced by)

pnpm-lock.yaml

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ catalogs:
9090
eslint-plugin-better-max-params: 1.0.0
9191
eslint-plugin-comment-length: 2.3.1
9292
eslint-plugin-de-morgan: 2.1.3
93-
eslint-plugin-flawless: 1.7.0
93+
eslint-plugin-flawless: 1.9.0
9494
eslint-plugin-format-lua: 2.0.0
9595
eslint-plugin-import-lite: 0.6.0
9696
eslint-plugin-jsdoc: 63.2.2

scripts/roblox-allowed-words-shared.ts

Lines changed: 127 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ const SOURCES = [
2121
"include/generated/enums.d.ts",
2222
];
2323

24+
/**
25+
* The sources whose properties are worth scraping as well as their type names.
26+
*
27+
* A property is as likely to name a variable as the type is - `autoZIndex`
28+
* holds a `ZIndex`, and no type is called `ZIndex`. Enum items are excluded:
29+
* they are only ever reached as `Enum.TextXAlignment.Center`, never declared.
30+
*/
31+
const MEMBER_SOURCES = ["include/roblox.d.ts", "include/generated/None.d.ts"];
32+
2433
/**
2534
* `declare const CFrame: CFrameConstructor` - the datatype values.
2635
*/
@@ -37,18 +46,40 @@ const TOP_LEVEL_INTERFACE = /^interface (\w+)/gmu;
3746
*/
3847
const ENUM_NAMESPACE = /^[\t ]*export namespace (\w+)/gmu;
3948

49+
/**
50+
* `ZIndex: number` - the data properties declared inside an interface, which
51+
* the leading indent distinguishes from the interface itself.
52+
*
53+
* Methods are deliberately excluded. A property lends its spelling to whatever
54+
* holds it, but a call is written out at the call site, where the API spelling
55+
* is already required and no naming rule applies - so `Color3.ToHSV` is no
56+
* reason to let a variable be called `toHSV`, and `toHsv` is the name to use.
57+
* All three declaration forms count as a method: `ToHSV(this: Color3): T`,
58+
* `Name<T>(value: T): T`, and `toHSV: (color: Color3) => T`. The lookahead
59+
* does its own whitespace skip, because a negative lookahead after `\s*` would
60+
* backtrack to zero width and then test the space rather than the `(`.
61+
*/
62+
const INTERFACE_MEMBER = /^[\t ]+(?:readonly )?(\w+)\??\s*:(?=\s*[^\s(<])/gmu;
63+
4064
const CONSECUTIVE_CAPITALS = /[A-Z]{2}/u;
4165

66+
/**
67+
* A name ending in a capital is only a problem once something follows it, and
68+
* in either strict format the next word starts with a capital - `Motor6D` is
69+
* fine on its own but `motor6DWeld` is not.
70+
*/
71+
const TRAILING_CAPITAL = /[A-Z]$/u;
72+
4273
/**
4374
* The Roblox names a `strictCamelCase` / `StrictPascalCase` identifier cannot
4475
* spell without an escape.
4576
*
46-
* Only names holding two capitals in a row need listing - those are the only
47-
* ones the strict formats reject - which turns the ~1500 declared names into
48-
* around 80. The list is then pruned: a name is dropped when the words already
49-
* kept resolve it anyway, so `CFrame` absorbs `CFrameValue`,
50-
* `CFrameConstructor` and `UserCFrame`, and `UDim` absorbs `UDim2`. Candidates
51-
* are visited shortest-first so the more general word is always the one kept.
77+
* Only names that can put two capitals in a row need listing, which turns the
78+
* ~7000 declared names and members into a few hundred. The list is then pruned:
79+
* a name is dropped when the words already kept resolve it anyway, so `CFrame`
80+
* absorbs `CFrameValue`, `CFrameConstructor` and `UserCFrame`, `UDim` absorbs
81+
* `UDim2`, and `ZIndex` absorbs `ZIndexBehavior`. Candidates are visited
82+
* shortest-first so the more general word is always the one kept.
5283
*
5384
* Comparisons are by code unit rather than `localeCompare`, so the output does
5485
* not depend on the host locale.
@@ -58,13 +89,13 @@ const CONSECUTIVE_CAPITALS = /[A-Z]{2}/u;
5889
export async function deriveRobloxAllowedWords(): Promise<Array<string>> {
5990
const declared = await readDeclaredNames();
6091
const candidates = [...declared]
61-
.filter((name) => CONSECUTIVE_CAPITALS.test(name))
92+
.filter((name) => needsAllowing(name))
6293
.sort((left, right) => left.length - right.length || (left < right ? -1 : 1));
6394

6495
const kept: Array<string> = [];
6596
for (const candidate of candidates) {
6697
const longestFirst = kept.toSorted((left, right) => right.length - left.length);
67-
if (CONSECUTIVE_CAPITALS.test(applyAllowedWords(candidate, longestFirst))) {
98+
if (needsAllowing(applyAllowedWords(candidate, longestFirst))) {
6899
kept.push(candidate);
69100
}
70101
}
@@ -83,11 +114,24 @@ function isUppercaseCharacter(character: string): boolean {
83114
return character >= "A" && character <= "Z";
84115
}
85116

117+
/**
118+
* The word with its first character lowercased, which is how a word reads at
119+
* the start of a `strictCamelCase` name.
120+
*
121+
* @param word - The word in its API spelling.
122+
* @returns The word with a lowercase first character.
123+
*/
124+
function lowercaseInitial(word: string): string {
125+
return word.slice(0, 1).toLowerCase() + word.slice(1);
126+
}
127+
86128
/**
87129
* The longest allowed word starting at `index`, if that position opens a hump.
88130
*
89131
* A word only matches at the start of the name or after a character that is not
90-
* uppercase, so it can never split an existing hump.
132+
* uppercase, so it can never split an existing hump. At the start the word also
133+
* matches in its lowercased-initial form, because that is the only spelling
134+
* `strictCamelCase` permits there - `motor6DWeld` holds `Motor6D`.
91135
*
92136
* @param name - The name being rewritten.
93137
* @param allowedWords - The words to look for, longest first.
@@ -99,8 +143,13 @@ function findWordAt(
99143
allowedWords: ReadonlyArray<string>,
100144
index: number,
101145
): string | undefined {
102-
const previous = index === 0 ? undefined : name[index - 1];
103-
if (previous !== undefined && isUppercaseCharacter(previous)) {
146+
if (index === 0) {
147+
return allowedWords.find((candidate) => {
148+
return name.startsWith(candidate) || name.startsWith(lowercaseInitial(candidate));
149+
});
150+
}
151+
152+
if (isUppercaseCharacter(name[index - 1] ?? "")) {
104153
return undefined;
105154
}
106155

@@ -132,34 +181,87 @@ function applyAllowedWords(name: string, allowedWords: ReadonlyArray<string>): s
132181
continue;
133182
}
134183

135-
result += word[0] + word.slice(1).toLowerCase();
184+
result += name.startsWith(word, index)
185+
? word.slice(0, 1) + word.slice(1).toLowerCase()
186+
: word.toLowerCase();
136187
index += word.length;
137188
}
138189

139190
return result;
140191
}
141192

142193
/**
143-
* Reads every declared name out of the `@rbxts/types` sources.
194+
* Whether a name can put two capitals in a row, which is what the strict
195+
* formats reject.
196+
*
197+
* Two ways to get there: the name already holds a pair (`CFrame`), or it ends
198+
* in a capital and so collides with whatever word follows it in an identifier
199+
* (`Motor6D` in `motor6DWeld`). Checking the name in isolation catches only the
200+
* first.
144201
*
145-
* @returns The union of datatype, Instance and enum names.
202+
* @param name - The name to test.
203+
* @returns True when the name needs to be in the list.
204+
*/
205+
function needsAllowing(name: string): boolean {
206+
// A one-character word folds to itself - `applyAllowedWords` only lowercases
207+
// a word's tail, and a single capital has none - so listing `X` can never
208+
// change an outcome. Members like `Vector3.X` would otherwise qualify under
209+
// the trailing-capital arm and sit in the list doing nothing.
210+
if (name.length < 2) {
211+
return false;
212+
}
213+
214+
return CONSECUTIVE_CAPITALS.test(name) || TRAILING_CAPITAL.test(name);
215+
}
216+
217+
/**
218+
* Adds every capture of every pattern to `names`.
219+
*
220+
* @param content - The file to scan.
221+
* @param patterns - The global-flagged patterns to run, each capturing a name.
222+
* @param names - The set to add to.
223+
*/
224+
function collectNames(content: string, patterns: Array<RegExp>, names: Set<string>): void {
225+
for (const pattern of patterns) {
226+
for (const [, name] of content.matchAll(pattern)) {
227+
if (name !== undefined) {
228+
names.add(name);
229+
}
230+
}
231+
}
232+
}
233+
234+
/**
235+
* Reads one `@rbxts/types` source.
236+
*
237+
* @param root - The resolved package root.
238+
* @param source - The path within the package.
239+
* @returns The file contents.
240+
*/
241+
async function readSource(root: string, source: string): Promise<string> {
242+
return fs.readFile(path.join(root, source), "utf8");
243+
}
244+
245+
/**
246+
* Reads every name an identifier can be built from out of `@rbxts/types`.
247+
*
248+
* @returns The union of datatype, Instance, enum and member names.
146249
*/
147250
async function readDeclaredNames(): Promise<Set<string>> {
148251
const require = createRequire(import.meta.url);
149252
const root = path.dirname(require.resolve("@rbxts/types/package.json"));
150-
const contents = await Promise.all(
151-
SOURCES.map(async (source) => fs.readFile(path.join(root, source), "utf8")),
152-
);
253+
const [typeSources, memberSources] = await Promise.all([
254+
Promise.all(SOURCES.map(async (source) => readSource(root, source))),
255+
Promise.all(MEMBER_SOURCES.map(async (source) => readSource(root, source))),
256+
]);
153257

154258
const names = new Set<string>();
155-
for (const content of contents) {
156-
for (const pattern of [DECLARE_CONST, TOP_LEVEL_INTERFACE, ENUM_NAMESPACE]) {
157-
for (const [, name] of content.matchAll(pattern)) {
158-
if (name !== undefined) {
159-
names.add(name);
160-
}
161-
}
162-
}
259+
for (const content of typeSources) {
260+
collectNames(content, [DECLARE_CONST, TOP_LEVEL_INTERFACE, ENUM_NAMESPACE], names);
261+
}
262+
263+
for (const content of memberSources) {
264+
collectNames(content, [INTERFACE_MEMBER], names);
163265
}
164266

165267
return names;

src/generated/oxlint-capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export const jsPluginRuleNames: ReadonlySet<string> = new Set([
128128
"flawless/no-export-default-arrow",
129129
"flawless/no-floating-point-equality",
130130
"flawless/no-redundant-tsconfig-options",
131+
"flawless/no-shared-mocks",
131132
"flawless/no-unnecessary-use-callback",
132133
"flawless/no-unnecessary-use-memo",
133134
"flawless/padding-after-expect-assertions",

0 commit comments

Comments
 (0)