wow-secret-lint 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,77 @@
1
+ # Changelog
2
+
3
+ ## 1.3.0 - 2026-08-31
4
+
5
+ Patch 12.1 ("Curse of Ula'tek", live 2026-08-11) widened the secret surface far beyond
6
+ 12.0, and 1.2.0 reported a false clean on the most common aura pattern in retail addons.
7
+ This release adds the 12.1 surface and a `--patch` flag to pin the old one.
8
+
9
+ ### Added
10
+
11
+ - `--patch=<12.0|12.1>` (default `12.1`), also exposed as the `patch` input of the GitHub
12
+ Action. `12.0` reproduces the 1.2.0 rule surface byte for byte, for addons still
13
+ targeting the older client.
14
+ - **WSL012** (error): forbidden operation on a secret aura vector. Taints the returns of
15
+ `C_UnitAuras.GetUnitAuras`, `GetUnitAuraInstanceIDs`, `GetAuraSlots`, the
16
+ by-index/slot/instance aura queries and the by-spell lookups; flags `#`,
17
+ `pairs`/`ipairs` iteration, indexing and table-key use. Boolean-testing the return stays
18
+ silent, because nil-checking it is the sanctioned pattern, and the shipped guard idiom
19
+ (`if not aura or issecretvalue(aura.name) then return end`, as in DBM and BigWigs) is
20
+ credited in full.
21
+ - **WSL013** (error): forbidden operation on a secret unit identity value, covering
22
+ `UnitClass`, `UnitClassBase`, `UnitRace`, `UnitSex`, `UnitSexBase`, `UnitIsCharmed` and
23
+ `UnitIsPossessed`, routed through the existing arithmetic, comparison and boolean-test
24
+ checkers. Literal `"player"` (and `"player"`/`"pet"`/`"vehicle"` for the charm pair, per
25
+ the 12.1 notes) is exempt.
26
+ - **WSL014** (error): removed or renamed symbols, each message naming the replacement:
27
+ `SecureAuraHeaderTemplate`, `C_UnitAuras.TriggerPrivateAuraShowDispelType`,
28
+ `CanAccessObject`, `UIParentLoadAddOn`.
29
+ - **WSL015** (warning): deprecated `getglobal`/`setglobal`.
30
+ - **WSL016** (warning): `showCountdownFrame` passed to a private-aura API. The field was
31
+ renamed to `showCooldownFrame` and the old name is silently ignored, so the cooldown
32
+ swipe disappears with no Lua error. Caught inline and through a local args table.
33
+ - **WSL017** (error): forbidden-aspect operations on AuraButtons and AuraContainers:
34
+ script handler installation, event registration, input calls and focus queries, matched
35
+ to the UntrustedScriptExecution, EventRegistrations, ScriptedInput and QueryFocus
36
+ aspects. AuraButtons are recognised from `CreateFrame` and from `initializeFrame`
37
+ callbacks; construction itself is never flagged.
38
+
39
+ ### Measured
40
+
41
+ On the same 12-addon corpus as 1.2.0: `--patch=12.0` reproduces 1.2.0 exactly (20 errors,
42
+ 90 warnings, 3 of 12 failing). The default 12.1 surface reports 273 errors and 117
43
+ warnings, failing 8 of 12, dominated by WSL012 (148) and WSL013 (104). DBM and BigWigs
44
+ report zero WSL012 because both already guard every aura lookup; the findings sit in the
45
+ addons that have not done that work yet. WSL016 catches the `showCountdownFrame` in oUF's
46
+ `privateauras.lua` and both vendored copies of it (KkthnxUI, SpartanUI).
47
+
48
+ WSL012 and WSL013 gate builds without `--strict`. Unlike the `SecretReturns` tier, they
49
+ rest on the 12.1 notes stating the behaviour outright ("will now either return full
50
+ secrets or nil when called by addons") rather than on a documentation marker.
51
+
52
+ ### Clone check
53
+
54
+ The six rules reuse the existing machinery rather than shipping parallel bodies: WSL012
55
+ and WSL013 are taint seeds routed through the existing operator checkers via a one-line
56
+ rule-resolution step, and WSL014/WSL015 share one table-driven removed-call check. The
57
+ new standalone bodies (WSL016 field tracking, WSL017 widget tagging) parallel nothing in
58
+ the codebase; highest pairwise similarity across all new functions is below 40%.
59
+
60
+ ## 1.2.0 - 2026-08-24
61
+
62
+ - Flavour-aware `.toc` discovery: descends up to three levels, skips folders whose every
63
+ `.toc` targets Classic, announces blind walks. Fixed WeakAuras-style repos silently
64
+ bypassing the `.toc` mechanism. Classic contamination in strict findings: 8 of 118 to 0
65
+ of 110.
66
+
67
+ ## 1.1.0 - 2026-08-24
68
+
69
+ - Only `WSL008` fails a build by default; everything resting on `SecretReturns` reports
70
+ as a warning, with `--strict` to raise it. The default is correct under either reading
71
+ of the documentation.
72
+
73
+ ## 1.0.0 - 2026-08-24
74
+
75
+ - First release: rules WSL001-WSL011, taint tracking, guard detection, `.toc`/XML
76
+ resolution, three reporters, GitHub Action, vendored snapshot of Blizzard's generated
77
+ API documentation.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # wow-secret-lint
2
2
 
3
- Static analysis for World of Warcraft **retail** addons. It maps where your Lua touches Blizzard's secret-value API, and hard-fails your build on the one thing that is certain to break.
3
+ Static analysis for World of Warcraft **retail** addons. It maps where your Lua touches Blizzard's secret-value API, and hard-fails your build on the things Blizzard documents as certain to break.
4
4
 
5
- Patch 12.0 introduced [secret values](https://warcraft.wiki.gg/wiki/Secret_Values). A lot of the API now hands your addon a value you are allowed to store, pass around and print, but not to do arithmetic on, compare, index, call, or measure with `#`. When tainted code does one of those, the wiki is blunt about the result:
5
+ Patch 12.0 introduced [secret values](https://warcraft.wiki.gg/wiki/Secret_Values). A lot of the API now hands your addon a value you are allowed to store, pass around and print, but not to do arithmetic on, compare, index, call, or measure with `#`. Patch 12.1 (Curse of Ula'tek, live 2026-08-11) [widened that surface enormously](https://warcraft.wiki.gg/wiki/Patch_12.1.0/API_changes): every `UnitAura` API returns full secrets or nil while auras are secret, seven unit identity APIs joined them, and the new AuraButton/AuraContainer frames carry forbidden aspects. When tainted code does something disallowed, the wiki is blunt about the result:
6
6
 
7
7
  > When an operation that is not allowed is performed, the result will be an **immediate** Lua error.
8
8
 
@@ -17,7 +17,7 @@ That one is [KkthnxUI#121](https://github.com/Kkthnx-Wow/KkthnxUI/issues/121), f
17
17
 
18
18
  `wow-secret-lint` reads Blizzard's own generated API documentation, tracks which of your locals hold a secret, and tells you where you touch one.
19
19
 
20
- **It reports, it does not accuse.** Exactly one rule fails your build by default: `WSL008`, registering a combat log event, which Blizzard documents as a hard error. Everything else is a warning, because whether those APIs really hand a secret to tainted code on every call is not something static analysis can confirm. Run `--strict` to gate on the rest once you have decided you want that. [The reasoning is spelled out below](#the-open-question-on-severity) and it is worth two minutes before you wire this into CI.
20
+ **It reports, it does not accuse.** The rules that fail your build by default are the ones Blizzard states outright: `WSL008` (combat log registration errors), and the 12.1 rules `WSL012`/`WSL013` (aura and identity APIs "will now either return full secrets or nil when called by addons"), `WSL014` (the symbol no longer exists) and `WSL017` (forbidden aspects). The 12.0-era `SecretReturns` tier stays a warning, because whether those APIs really hand a secret to tainted code on every call is not something static analysis can confirm; run `--strict` to gate on it. [The reasoning is spelled out below](#the-open-question-on-severity) and it is worth two minutes before you wire this into CI.
21
21
 
22
22
  ## Install
23
23
 
@@ -74,6 +74,25 @@ $ npx wow-secret-lint Core/Guarded.lua
74
74
 
75
75
  Exit code 0.
76
76
 
77
+ ### The 12.1 aura surface
78
+
79
+ The most common aura pattern in retail addons stopped working in 12.1. Per the [12.1 API changes](https://warcraft.wiki.gg/wiki/Patch_12.1.0/API_changes): *"APIs like GetUnitAuras and GetUnitAuraInstanceIDs will return a secret vector, meaning addon code will not be able to determine how many auras it contains or iterate through it."* `Core/Auras.lua`:
80
+
81
+ ```lua
82
+ local auras = C_UnitAuras.GetUnitAuras('player'); for i = 1, #auras do print(auras[i].name) end
83
+ ```
84
+
85
+ ```
86
+ $ npx wow-secret-lint Core/Auras.lua
87
+ Core/Auras.lua:1:63 error WSL012 length operator (#) on a secret value: 'auras' derives from C_UnitAuras.GetUnitAuras(), secret in 12.1 while auras are secret (combat, encounters, M+, PvP); show auras with an AuraContainer (AddAuraGroup/AddAuraSlot) instead
88
+ Core/Auras.lua:1:78 error WSL012 indexed access on a secret value: 'auras' derives from C_UnitAuras.GetUnitAuras(), secret in 12.1 while auras are secret (combat, encounters, M+, PvP); show auras with an AuraContainer (AddAuraGroup/AddAuraSlot) instead
89
+ 2 errors, 0 warnings
90
+ ```
91
+
92
+ Exit code 1. Under `--patch=12.0` the same file reports zero findings and exits 0, because the pre-12.1 API really did return an iterable table. `if auras then` stays silent under both, since nil-checking the return is the sanctioned pattern.
93
+
94
+ None of this comes from Blizzard's generated documentation, which carries no marker for aura secrecy. The 12.1 behaviour is published only in the patch notes, so these rules seed their taint from a list transcribed there, and `--patch` selects whether that list is active.
95
+
77
96
  ### Exit codes
78
97
 
79
98
  | Code | Meaning |
@@ -107,6 +126,7 @@ Inputs:
107
126
  | Input | Default | Description |
108
127
  | --- | --- | --- |
109
128
  | `path` | `.` | addon folder, `.toc`, or `.lua`. Several allowed, space separated |
129
+ | `patch` | `12.1` | patch surface to check; `12.0` pins the pre-12.1 rule set |
110
130
  | `args` | `""` | extra CLI arguments, e.g. `--conditional=warn --disable=WSL011` |
111
131
  | `format` | `github` | `github`, `stylish`, or `json` |
112
132
 
@@ -114,7 +134,7 @@ Outputs: `errors`, `warnings`.
114
134
 
115
135
  ## Rules
116
136
 
117
- Every rule maps to one sentence Blizzard publishes. `wow-secret-lint --rules` prints the table with the source for each. Only `WSL008` fails a build by default.
137
+ Every rule maps to one sentence Blizzard publishes. `wow-secret-lint --rules` prints the table with the source for each. WSL008, WSL012, WSL013, WSL014 and WSL017 fail a build by default; the rest are warnings.
118
138
 
119
139
  | Rule | Severity | What it catches |
120
140
  | --- | --- | --- |
@@ -129,11 +149,29 @@ Every rule maps to one sentence Blizzard publishes. `wow-secret-lint --rules` pr
129
149
  | WSL009 | warning | secret crosses into a function in this file that never guards it |
130
150
  | WSL010 | warning | conditionally secret value used with no guard anywhere in scope |
131
151
  | WSL011 | warning | `tostring()` on a secret value |
152
+ | WSL012 | error | 12.1: `#`, iteration, indexing or table-key use of a secret aura vector |
153
+ | WSL013 | error | 12.1: arithmetic, comparison or boolean test on a secret unit identity value |
154
+ | WSL014 | error | 12.1: removed or renamed symbol; each message names the replacement |
155
+ | WSL015 | warning | 12.1: deprecated `getglobal`/`setglobal` |
156
+ | WSL016 | warning | 12.1: `showCountdownFrame` passed to a private-aura API |
157
+ | WSL017 | error | 12.1: forbidden-aspect operation on an AuraButton or AuraContainer |
132
158
 
133
159
  WSL008 comes from the [12.0.0 API changes](https://warcraft.wiki.gg/wiki/Patch_12.0.0/API_changes): *"COMBAT_LOG_EVENT and COMBAT_LOG_EVENT_UNFILTERED will error when trying to register them."* Use `COMBAT_LOG_EVENT_INTERNAL_UNFILTERED` or the `C_CombatLog` namespace.
134
160
 
135
161
  WSL011 is a warning on purpose. `tostring` is absent from the wiki's allowed list but is not listed as forbidden either, so it stays a warning until someone confirms it in game.
136
162
 
163
+ ### The 12.1 rules in detail
164
+
165
+ **WSL012** taints `C_UnitAuras.GetUnitAuras`, `GetUnitAuraInstanceIDs`, `GetAuraSlots`, the by-index/slot/instance queries (`GetAuraDataByIndex`, `GetAuraDataBySlot`, `GetAuraDataByAuraInstanceID`, `GetBuffDataByIndex`, `GetDebuffDataByIndex`) and the by-spell lookups (`GetAuraDataBySpellName`, `GetUnitAuraBySpellID`, `GetPlayerAuraBySpellID`, `GetCooldownAuraBySpellID`), since the notes say all of the UnitAura APIs return full secrets or nil. The by-index family additionally Lua-errors at the call itself while auras are secret, so any downstream use of its result is broken either way. Boolean tests on aura returns are never flagged: the value is a table or nil, and nil-checking it is how the new contract is meant to be consumed. The guard idiom DBM and BigWigs actually ship, `if not aura or issecretvalue(aura.name) then return end`, is credited in full: indexing one field inside a guard call is the test itself, and a guard naming any field vouches for the whole aura. Auras Blizzard explicitly flags as non-secret still come back readable; static analysis cannot know which spell IDs those are, so an unguarded read of one is still reported.
166
+
167
+ **WSL013** covers the seven unit APIs the 12.1 notes single out: `UnitClass`, `UnitClassBase`, `UnitRace`, `UnitSex`, `UnitSexBase`, plus `UnitIsCharmed`/`UnitIsPossessed` which follow aura secrecy. The findings come out of the same arithmetic, comparison and boolean-test checkers as WSL001/002/007; only the rule id and severity differ. A literal `"player"` token is exempt (a unit is never secret to itself), and the charm pair is exempt for `"player"`, `"pet"` and `"vehicle"`, which the 12.1 notes state outright. A variable token is not exempt, because it can name an arena opponent. The wider identity set from the notes (`UnitGroupRolesAssigned`, `UnitInRaid`, `UnitIsPVP` and friends) stays in the conditional tier for now: turn on `--conditional=warn` to see it.
168
+
169
+ **WSL014** flags calls to `UIParentLoadAddOn` (renamed `LoadAddOnWithErrorHandling`), `CanAccessObject` (use `FrameScriptObject:CanBeAccessedInContext`), `C_UnitAuras.TriggerPrivateAuraShowDispelType` (removed; use the `showDispelIcon` anchor option) and any `CreateFrame` template list naming `SecureAuraHeaderTemplate` (removed from Mainline; migrate to an AuraContainer). Referencing `UIParentLoadAddOn` without calling it is not flagged, because `if UIParentLoadAddOn then` is how you feature-detect the old client.
170
+
171
+ **WSL016** exists because this one fails silently: `showCountdownFrame` was renamed `showCooldownFrame` in the private-aura anchor structures, the old field is ignored, and the cooldown swipe just stops appearing with no error to trace. It is caught both inline and through a local args table. oUF's `privateauras.lua` ships this bug today, as do the copies vendored into KkthnxUI and SpartanUI.
172
+
173
+ **WSL017** knows a local is an AuraContainer or AuraButton when it comes from `CreateFrame("AuraContainer", ...)`/`CreateFrame("AuraButton", ...)` or arrives as the `initializeFrame` callback parameter of `AddAuraGroup`/`AddAuraSlot`/`AddItemEnchantment`. On those it flags `SetScript`/`HookScript`, event registration, input calls (`EnableMouse`, `RegisterForClicks`, `Click`, ...) and focus queries (`IsMouseOver`, ...), naming the forbidden aspect each one trips. Construction itself is never flagged.
174
+
137
175
  ### What it will never flag
138
176
 
139
177
  The wiki has an explicit allowed list, and flagging any of it is a false positive that gets a linter uninstalled. Each of these has a passing negative test in `test/`:
@@ -165,7 +203,7 @@ npx wow-secret-lint ./MyAddon --secret-guard=IsLocked --access-guard=CanRead
165
203
 
166
204
  Blizzard marks a second tier in the generated docs: functions that return a secret only while a specific restriction is active. `C_Spell.GetSpellCooldown` carries `SecretWhenCooldownsRestricted`; `C_LFGList.GetSearchResultInfo` carries `SecretInChatMessagingLockdown`; `UnitGUID`, `UnitName` and `UnitClass` carry `SecretWhenUnitIdentityRestricted`. In practice that means "secret in PvP, in restricted instances, and for non-player or pet units in combat". Your code works fine right up until it does not.
167
205
 
168
- That tier is **off by default**, because measured against 12 real addons it produces about one warning per Lua file, which is a tax rather than a signal. Turn it on for a deeper audit:
206
+ Under the default `--patch=12.1`, the seven identity APIs WSL013 covers are promoted out of this tier and report as errors regardless of the `--conditional` setting. The rest of the tier is **off by default**, because measured against 12 real addons it produces about one warning per Lua file, which is a tax rather than a signal. Turn it on for a deeper audit:
169
207
 
170
208
  ```bash
171
209
  npx wow-secret-lint ./MyAddon --conditional=warn # report as warnings
@@ -194,23 +232,29 @@ That is [BtWLoadouts#67](https://github.com/Breeni/BtWLoadouts/issues/67), reduc
194
232
 
195
233
  ## Measured on real addons
196
234
 
197
- Run against 12 real retail addons (BigWigs, LittleWigs, DBM, WeakAuras, Details, SpartanUI, KkthnxUI, oUF, Bartender4, Premade Groups Filter, BtWLoadouts, AdvancedInterfaceOptions), 2,209 Lua files:
235
+ Run against 12 real retail addons (BigWigs, LittleWigs, DBM, WeakAuras, Details, SpartanUI, KkthnxUI, oUF, Bartender4, Premade Groups Filter, BtWLoadouts, AdvancedInterfaceOptions), 1,997 Lua files reachable from a retail `.toc`:
198
236
 
199
237
  | Mode | Errors | Warnings | Addons that would fail CI |
200
238
  | --- | --- | --- | --- |
201
- | default | 20 | 90 | 3 of 12 |
202
- | `--strict` | 110 | 0 | 5 of 12 |
239
+ | `--patch=12.1` (default) | 273 | 117 | 8 of 12 |
240
+ | `--patch=12.1 --strict` | 383 | 7 | 8 of 12 |
241
+ | `--patch=12.0` | 20 | 90 | 3 of 12 |
242
+ | `--patch=12.0 --strict` | 110 | 0 | 5 of 12 |
243
+
244
+ The two `12.0` rows are identical to what v1.2.0 reported, which is the point of the flag. The jump to 273 is the 12.1 aura and identity surface landing: 148 WSL012 and 104 WSL013 findings, concentrated exactly where the [PTR forum thread](https://us.forums.blizzard.com/en/wow/t/minicc-and-similar-addons-might-be-partially-broken-in-121/2310937) predicted breakage: Details' aura scanning (80 WSL012), SpartanUI's aura designer and corner indicators (56), oUF's classpower element and the copy of it inside KkthnxUI (5 each), DBM's class-keyed tables (50 WSL013), BigWigs (20 WSL013). WSL016 catches the silently dead `showCountdownFrame` in oUF `privateauras.lua:133` and both copies of it vendored into KkthnxUI and SpartanUI; WSL014 catches SpartanUI calling the renamed `UIParentLoadAddOn`. No addon in the corpus uses AuraContainers yet, so WSL017 measures 0 there and is exercised by fixtures.
203
245
 
204
- Every one of the 20 default errors is `WSL008`, a combat log event registration that Blizzard documents as failing. Those are worth fixing today and nothing else in the default output can fail your build.
246
+ The number worth noticing is the one that is zero: **DBM and BigWigs report no WSL012 at all**, because both already wrap every aura lookup in an `issecretvalue` guard, and the analysis credits that idiom (see below). The linter separates code that has done the 12.1 work from code that has not.
205
247
 
206
- **All 110 are reachable from a retail `.toc`**, checked by resolving every retail `.toc` in each repo and matching it against the findings. Contamination from Classic-flavour code is 0. It was 8 before flavour-aware discovery landed, all of them WeakAuras, which ships only Classic `.toc` files in its repo and was being scanned by a blind directory walk.
248
+ A sample of the WSL012/WSL013 findings across every affected repo was read against its source by hand; each one performs an operation the 12.1 notes document as disallowed, on a value those notes document as secret, with no guard in scope. The pre-12.1 findings are unchanged from v1.2.0, where all 110 strict findings were read by hand.
207
249
 
208
- All 110 were read against their source line by hand and **none contradicts the documented rule**. That is the strongest claim this project can honestly make, and it is not the same as "these 118 lines error in game". Read the next section before you reach for `--strict`.
250
+ Contamination from Classic-flavour code is 0, checked by resolving every retail `.toc` in each repo and matching it against the findings.
209
251
 
210
- SpartanUI's `libs/oUF_Classic` and `libs/LibClassicDurations` do get flagged and that is correct: its single `.toc` declares `## Interface: 120100, 50504, 38002, 20506, 11509`, so those libraries are listed for retail too. Blizzard's own `BlizzardInterfaceCode` is in the corpus only as a parser stress test, 2,274 files with 0 parse errors, since its code runs untainted and cannot violate these rules.
252
+ SpartanUI's `libs/oUF_Classic` and `libs/LibClassicDurations` do get flagged and that is correct: its single `.toc` declares `## Interface: 120100, 50504, 38002, 20506, 11509`, so those libraries are listed for retail too. Blizzard's own `BlizzardInterfaceCode` is in the corpus only as a parser stress test, 2,128 retail files with 0 parse errors, since its code runs untainted and cannot violate these rules.
211
253
 
212
254
  ## The open question on severity
213
255
 
256
+ This section is about the 12.0-era `SecretReturns` tier (WSL001-WSL007 on `UnitHealth` and friends), which stays a warning by default. It does not apply to WSL012/WSL013: those rest on the 12.1 notes stating the behaviour in plain language, not on a documentation marker whose runtime meaning is unconfirmed.
257
+
214
258
  The Secret Values page says functions marked `SecretReturns = true` "unconditionally return secret values", and that is what the error tier is built on. There is evidence pulling the other way and you should know about it.
215
259
 
216
260
  Searching the issue trackers of DeadlyBossMods, BigWigs, Details and WeakAuras finds **zero** reports mentioning `UnitHealth` and secret values, while this tool flags dozens of `UnitHealth`-derived findings across them. If `UnitHealth` really did hand a secret to tainted code on every call, `UnitHealth(uId) / UnitHealthMax(uId) * 100` in DBM-Core would throw on every boss pull for millions of users, and the trackers would show it.
@@ -229,6 +273,8 @@ Settling it needs someone to run a flagged line in game and watch what happens,
229
273
  ```
230
274
  --format=<stylish|json|github> output format (default: stylish)
231
275
  --game=<retail|classic> classic has no secret values and exits 0 immediately
276
+ --patch=<12.0|12.1> patch surface the built-in rules check (default: 12.1);
277
+ 12.0 pins the pre-12.1 rule set
232
278
  --strict raise SecretReturns findings from warning to error
233
279
  --conditional=<off|warn|error> conditionally secret APIs (default: off)
234
280
  --secret-guard=<names> extra is-secret wrapper functions, comma separated
@@ -252,6 +298,8 @@ npx wow-secret-lint --refresh
252
298
 
253
299
  A scheduled workflow in this repo does that weekly and opens a pull request when the docs move.
254
300
 
301
+ The 12.1 aura and identity secrecy is deliberately **not** part of the snapshot: Blizzard's generated documentation carries no marker for it, only the patch notes describe it, so the WSL012/WSL013 API lists live in `src/rules.mjs` with the source quoted, and `--patch` gates them. There is one snapshot, and `--snapshot`/`--refresh` behave the same under either patch.
302
+
255
303
  ## Limitations and non-goals
256
304
 
257
305
  - **No auto-fixing.** It tells you where; the fix is yours.
@@ -259,7 +307,8 @@ A scheduled workflow in this repo does that weekly and opens a pull request when
259
307
  - **No Classic support.** Classic has no secret values, so `--game=classic` exits 0 immediately.
260
308
  - **No cross-file interprocedural analysis in v1.** Taint follows plain assignment, table field stores, the return value of a file-local function, and one level of intra-file call-argument passing. A secret that leaves through a global and comes back in another file is not tracked.
261
309
  - **No LuaJIT or Lua 5.4 syntax.** Files are parsed as Lua 5.1. WoW accepts a semicolon after `break`, which stock 5.1 does not, so a file that fails on 5.1 gets one retry under the 5.2 grammar before it is reported as a parse error.
262
- - **Method calls are not resolved to a widget type**, so WSL006 only applies to plain and namespaced calls (`UnitHealth(...)`, `C_CVar.SetCVar(...)`), never to `frame:SetText(...)`.
310
+ - **Method calls are not resolved to a widget type**, so WSL006 only applies to plain and namespaced calls (`UnitHealth(...)`, `C_CVar.SetCVar(...)`), never to `frame:SetText(...)`. WSL017 is the one exception, and its typing is deliberately narrow: a local counts as an AuraContainer/AuraButton only when it comes straight from `CreateFrame` with a literal type string or from an `initializeFrame` callback. A button stored in a table field or passed across files is not tracked.
311
+ - **XML is followed for `<Script>`/`<Include>`, not linted.** A `SecureAuraHeaderTemplate` referenced from an XML `inherits` attribute is invisible to WSL014; only Lua-side `CreateFrame` template strings are checked.
263
312
  - **`string.format` output is not tracked as secret.** The wiki names it as the sanctioned way to render a secret, and following it would flood every `SetText` call site. The trade is a known blind spot on `#string.format(...)`.
264
313
  - A `.toc` listing a file that is not on disk warns and keeps going. A file that will not parse is reported and the run exits 2.
265
314
 
@@ -269,7 +318,7 @@ A scheduled workflow in this repo does that weekly and opens a pull request when
269
318
  npm test
270
319
  ```
271
320
 
272
- 123 tests. The suite covers every rule, the guard forms, the permitted-operations negative cases, the three reporters, the CLI surface, and eight regression fixtures reconstructed from real shipped traces:
321
+ 173 tests. The suite covers every rule, the guard forms, the permitted-operations negative cases, the three reporters, the CLI surface, one violating and one clean fixture per 12.1 rule (`test/fixtures/rules-121/`), a fixture proving `--patch=12.0` reproduces the v1.2.0 output byte for byte (`test/fixtures/patch/`), and eight regression fixtures reconstructed from real shipped traces:
273
322
 
274
323
  | Fixture | Issue |
275
324
  | --- | --- |
package/action/index.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import process from 'node:process';
9
9
  import { lintPaths, VERSION } from '../src/index.mjs';
10
10
  import { format, FORMATS, counts } from '../src/report.mjs';
11
- import { RULE_IDS } from '../src/rules.mjs';
11
+ import { RULE_IDS, PATCHES, DEFAULT_PATCH } from '../src/rules.mjs';
12
12
 
13
13
  const HERE = dirname(fileURLToPath(import.meta.url));
14
14
 
@@ -49,7 +49,15 @@ const fmt = input('format', 'github');
49
49
 
50
50
  if (!FORMATS.includes(fmt)) fail(`unknown format "${fmt}" (expected one of: ${FORMATS.join(', ')})`);
51
51
 
52
- const options = { conditional: 'off', strict: false, disable: [], secretGuards: [], accessGuards: [], game: 'retail' };
52
+ const options = {
53
+ conditional: 'off',
54
+ strict: false,
55
+ disable: [],
56
+ secretGuards: [],
57
+ accessGuards: [],
58
+ game: 'retail',
59
+ patch: input('patch', DEFAULT_PATCH),
60
+ };
53
61
  let maxWarnings = Infinity;
54
62
 
55
63
  for (let i = 0; i < extra.length; i++) {
@@ -68,6 +76,9 @@ for (let i = 0; i < extra.length; i++) {
68
76
  case '--game':
69
77
  options.game = val();
70
78
  break;
79
+ case '--patch':
80
+ options.patch = val();
81
+ break;
71
82
  case '--disable':
72
83
  options.disable.push(...val().split(',').map((s) => s.trim()).filter(Boolean));
73
84
  break;
@@ -94,6 +105,9 @@ if (!['off', 'warn', 'error'].includes(options.conditional)) {
94
105
  if (!['retail', 'classic'].includes(options.game)) {
95
106
  fail(`unknown --game "${options.game}" (expected retail or classic)`);
96
107
  }
108
+ if (!PATCHES.includes(options.patch)) {
109
+ fail(`unknown patch "${options.patch}" (expected ${PATCHES.join(' or ')})`);
110
+ }
97
111
  for (const id of options.disable) {
98
112
  if (!RULE_IDS.includes(id)) fail(`unknown rule id "${id}" in --disable`);
99
113
  }
package/action.yml CHANGED
@@ -10,6 +10,10 @@ inputs:
10
10
  description: 'Addon folder, .toc, or .lua file to lint. Accepts several, space separated.'
11
11
  required: false
12
12
  default: '.'
13
+ patch:
14
+ description: 'Patch surface to check: 12.1 (default) or 12.0 for addons pinned to the older client.'
15
+ required: false
16
+ default: '12.1'
13
17
  args:
14
18
  description: 'Extra CLI arguments, e.g. "--conditional=warn --disable=WSL011".'
15
19
  required: false
@@ -7,7 +7,7 @@ import process from 'node:process';
7
7
  import { lintPaths, VERSION } from '../src/index.mjs';
8
8
  import { format, FORMATS, counts } from '../src/report.mjs';
9
9
  import { refreshSnapshot, writeSnapshot, SNAPSHOT_PATH } from '../src/apidata.mjs';
10
- import { RULES, RULE_IDS } from '../src/rules.mjs';
10
+ import { RULES, RULE_IDS, PATCHES, DEFAULT_PATCH } from '../src/rules.mjs';
11
11
 
12
12
  const USAGE = `wow-secret-lint ${VERSION}
13
13
  Static analysis for World of Warcraft retail addons: finds Secret Value violations
@@ -21,6 +21,9 @@ Usage:
21
21
  Options:
22
22
  --format=<stylish|json|github> output format (default: stylish)
23
23
  --game=<retail|classic> classic has no secret values and exits 0 immediately
24
+ --patch=<12.0|12.1> which patch surface the built-in rules check
25
+ (default: 12.1). 12.0 pins the pre-12.1 rule set for
26
+ addons still targeting the older client
24
27
  --strict raise SecretReturns findings from warning to error.
25
28
  Off by default: see "the open question on severity"
26
29
  in the README before you gate CI on them.
@@ -52,6 +55,7 @@ function parseArgs(argv) {
52
55
  const opts = {
53
56
  format: 'stylish',
54
57
  game: 'retail',
58
+ patch: DEFAULT_PATCH,
55
59
  conditional: 'off',
56
60
  strict: false,
57
61
  disable: [],
@@ -78,6 +82,7 @@ function parseArgs(argv) {
78
82
  else if (arg === '--strict') opts.strict = true;
79
83
  else if (arg.startsWith('--format')) opts.format = value(arg, argv, () => i++);
80
84
  else if (arg.startsWith('--game')) opts.game = value(arg, argv, () => i++);
85
+ else if (arg.startsWith('--patch')) opts.patch = value(arg, argv, () => i++);
81
86
  else if (arg.startsWith('--conditional')) opts.conditional = value(arg, argv, () => i++);
82
87
  else if (arg.startsWith('--secret-guard')) opts.secretGuards.push(...value(arg, argv, () => i++).split(',').map((s) => s.trim()).filter(Boolean));
83
88
  else if (arg.startsWith('--access-guard')) opts.accessGuards.push(...value(arg, argv, () => i++).split(',').map((s) => s.trim()).filter(Boolean));
@@ -152,6 +157,7 @@ async function main() {
152
157
 
153
158
  if (!FORMATS.includes(opts.format)) fail(`unknown format "${opts.format}" (expected one of: ${FORMATS.join(', ')})`);
154
159
  if (!['retail', 'classic'].includes(opts.game)) fail(`unknown game "${opts.game}" (expected retail or classic)`);
160
+ if (!PATCHES.includes(opts.patch)) fail(`unknown --patch "${opts.patch}" (expected ${PATCHES.join(' or ')})`);
155
161
  if (!['warn', 'error', 'off'].includes(opts.conditional)) {
156
162
  fail(`unknown --conditional "${opts.conditional}" (expected warn, error or off)`);
157
163
  }
@@ -175,6 +181,7 @@ async function main() {
175
181
  try {
176
182
  merged = await lintPaths(opts.paths, {
177
183
  game: opts.game,
184
+ patch: opts.patch,
178
185
  conditional: opts.conditional,
179
186
  strict: opts.strict,
180
187
  disable: opts.disable,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wow-secret-lint",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Maps where a WoW retail addon touches Blizzard's secret-value API, and gates CI on the one error that is certain.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,6 +36,7 @@
36
36
  "data/api-snapshot.json",
37
37
  "action.yml",
38
38
  "README.md",
39
+ "CHANGELOG.md",
39
40
  "LICENSE"
40
41
  ],
41
42
  "engines": {
package/src/analyze.mjs CHANGED
@@ -19,6 +19,21 @@ import {
19
19
  COMBAT_LOG_EVENTS,
20
20
  COMBAT_LOG_REPLACEMENT,
21
21
  booleanTestSeverity,
22
+ DEFAULT_PATCH,
23
+ AURA_SECRET_APIS,
24
+ IDENTITY_SECRET_APIS,
25
+ SELF_EXEMPT_TOKENS,
26
+ AURA_STATE_IDENTITY_APIS,
27
+ CATEGORY_RULES,
28
+ ITERATORS,
29
+ AURA_SUGGESTION,
30
+ REMOVED_CALLS,
31
+ REMOVED_TEMPLATE,
32
+ REMOVED_TEMPLATE_MESSAGE,
33
+ RENAMED_STRUCT_FIELDS,
34
+ ASPECT_FRAME_TYPES,
35
+ AURA_GROUP_METHODS,
36
+ FORBIDDEN_ASPECT_METHODS,
22
37
  } from './rules.mjs';
23
38
 
24
39
  const ARITHMETIC = new Set(['+', '-', '*', '/', '%', '^']);
@@ -86,6 +101,10 @@ class Analyzer {
86
101
  this.bindingSeq = 0;
87
102
  this.callDepth = 0;
88
103
  this.analysedWithTaint = new Set();
104
+ this.patch121 = options.patch !== '12.0';
105
+ this.widgetOf = new Map(); // dotted path -> 'AuraContainer' | 'AuraButton'
106
+ this.tableFields = new Map(); // dotted path -> Map(fieldName -> key node)
107
+ this.guardArgDepth = 0; // > 0 while evaluating the arguments of a guard or scrubber call
89
108
  }
90
109
 
91
110
  // ---------------------------------------------------------------- findings
@@ -100,8 +119,13 @@ class Analyzer {
100
119
  // Findings that rest on SecretReturns report as warnings unless --strict. Whether those
101
120
  // APIs really hand a secret to tainted code on every call is unconfirmed in game, so the
102
121
  // default is an inventory of exposure, not a verdict. Rules with no taint behind them
103
- // (WSL008) are deterministic and keep their severity.
104
- if (taint && taint.kind === 'secret' && sev === 'error' && !this.options.strict) sev = 'warning';
122
+ // (WSL008, WSL014-017) are deterministic and keep their severity, as do the 12.1 aura
123
+ // and identity rules: the patch notes state the secrecy outright and every player enters
124
+ // the states that activate it.
125
+ const patchBreaking = ruleId === 'WSL012' || ruleId === 'WSL013';
126
+ if (taint && taint.kind === 'secret' && sev === 'error' && !this.options.strict && !patchBreaking) {
127
+ sev = 'warning';
128
+ }
105
129
  if (this.options.disable.has(ruleId)) return;
106
130
  const p = pos(node);
107
131
  const key = `${ruleId}:${p.line}:${p.column}`;
@@ -123,6 +147,12 @@ class Analyzer {
123
147
  describe(taint) {
124
148
  if (!taint) return 'a secret value';
125
149
  const name = taint.label ? `'${taint.label}' ` : '';
150
+ if (taint.category === 'aura') {
151
+ return `${name}derives from ${taint.origin}(), secret in 12.1 while auras are secret (combat, encounters, M+, PvP); ${AURA_SUGGESTION}`;
152
+ }
153
+ if (taint.category === 'identity') {
154
+ return `${name}derives from ${taint.origin}(), whose returns are secret in 12.1 while the unit's identity is secret`;
155
+ }
126
156
  if (taint.kind === 'conditional') {
127
157
  const why = taint.conditions && taint.conditions.length ? taint.conditions.join(', ') : 'a runtime restriction';
128
158
  return `${name}derives from ${taint.origin}() (conditionally secret: ${why})`;
@@ -156,7 +186,7 @@ class Analyzer {
156
186
 
157
187
  // ------------------------------------------------------------------ taint
158
188
 
159
- makeTaint({ kind, origin, type, conditions, structure, label, bindingId, container }) {
189
+ makeTaint({ kind, origin, type, conditions, structure, label, bindingId, container, category }) {
160
190
  return {
161
191
  kind,
162
192
  origin,
@@ -165,11 +195,18 @@ class Analyzer {
165
195
  structure: structure ?? null,
166
196
  // A documented structure is not itself a secret; its unmarked fields are.
167
197
  container: container === true,
198
+ // 'aura' or 'identity' reroutes operator findings to WSL012/WSL013.
199
+ category: category ?? null,
168
200
  label: label ?? null,
169
201
  bindingId: bindingId ?? null,
170
202
  };
171
203
  }
172
204
 
205
+ /** Operator findings on a categorised taint report as the 12.1 rule for that category. */
206
+ ruleFor(defaultId, taint) {
207
+ return taint && taint.category ? CATEGORY_RULES[taint.category] : defaultId;
208
+ }
209
+
173
210
  /** Resolve the callee of a CallExpression to a documented API name, or null. */
174
211
  calleeName(node) {
175
212
  const base = node.base;
@@ -191,6 +228,18 @@ class Analyzer {
191
228
  /** Taint carried by return position `index` (0-based) of a documented API call. */
192
229
  returnTaint(name, entry, index, node) {
193
230
  const ret = entry.returns && entry.returns[index] ? entry.returns[index] : null;
231
+ // Aura secrecy is behavioural, so the generated docs carry no marker for it and the
232
+ // 12.1 lists seed it here, ahead of the snapshot's own annotations. All return
233
+ // positions are secret, and no structure is recorded because the value is a full
234
+ // secret: reading a field of it is itself the violation.
235
+ if (this.patch121) {
236
+ if (AURA_SECRET_APIS.has(name)) {
237
+ return this.makeTaint({ kind: 'secret', origin: name, type: ret ? ret.type : null, category: 'aura' });
238
+ }
239
+ if (IDENTITY_SECRET_APIS.has(name) && !selfExempt(name, node)) {
240
+ return this.makeTaint({ kind: 'secret', origin: name, type: ret ? ret.type : null, category: 'identity' });
241
+ }
242
+ }
194
243
  if (ret && ret.neverSecret) return null;
195
244
  const type = ret ? ret.type : null;
196
245
  const structure = type && this.api.structures[type] ? type : null;
@@ -338,6 +387,14 @@ class Analyzer {
338
387
  if (recv) paths.push(recv);
339
388
  }
340
389
  if (!paths.length) return empty;
390
+ // An aura is secret or readable as a unit, so a guard naming one field of a
391
+ // category-tainted value (issecretvalue(aura.name)) vouches for the whole value.
392
+ for (const p of [...paths]) {
393
+ const root = p.split('.')[0];
394
+ if (root === p) continue;
395
+ const t = scope.get(root);
396
+ if (t && t.category) paths.push(root);
397
+ }
341
398
  const marks = paths.map((p) => ({ path: p, prefix: g.prefix }));
342
399
  return g.safeWhen ? { whenTrue: marks, whenFalse: [] } : { whenTrue: [], whenFalse: marks };
343
400
  }
@@ -544,14 +601,14 @@ class Analyzer {
544
601
  if (t.type === 'MemberExpression' || t.type === 'IndexExpression') {
545
602
  const baseTaint = this.taintOf(t.base, scope);
546
603
  if (baseTaint && !baseTaint.container && !baseTaint.structure) {
547
- this.report('WSL005', t, `indexed assignment on a secret value: ${this.describe(baseTaint)}`, {
604
+ this.report(this.ruleFor('WSL005', baseTaint), t, `indexed assignment on a secret value: ${this.describe(baseTaint)}`, {
548
605
  taint: baseTaint,
549
606
  });
550
607
  }
551
608
  if (t.type === 'IndexExpression') {
552
609
  const keyTaint = this.taintOf(t.index, scope);
553
610
  if (keyTaint) {
554
- this.report('WSL005', t.index, `secret value used as a table key: ${this.describe(keyTaint)}`, {
611
+ this.report(this.ruleFor('WSL005', keyTaint), t.index, `secret value used as a table key: ${this.describe(keyTaint)}`, {
555
612
  taint: keyTaint,
556
613
  });
557
614
  }
@@ -595,6 +652,7 @@ class Analyzer {
595
652
  this.cleared.delete(path);
596
653
  }
597
654
  }
655
+ if (this.patch121) this.trackPatchState(target, i < inits.length ? inits[i] : null);
598
656
  });
599
657
  return null;
600
658
  }
@@ -621,10 +679,10 @@ class Analyzer {
621
679
  const t = lt ?? rt;
622
680
  if (!t) return;
623
681
  if (ARITHMETIC.has(node.operator)) {
624
- this.report('WSL001', lt ? node.left : node.right, `arithmetic on a secret value: ${this.describe(t)}`, { taint: t });
682
+ this.report(this.ruleFor('WSL001', t), lt ? node.left : node.right, `arithmetic on a secret value: ${this.describe(t)}`, { taint: t });
625
683
  if (lt && rt) this.markReported(rt);
626
684
  } else if (RELATIONAL.has(node.operator)) {
627
- this.report('WSL002', lt ? node.left : node.right, `comparison of a secret value: ${this.describe(t)}`, { taint: t });
685
+ this.report(this.ruleFor('WSL002', t), lt ? node.left : node.right, `comparison of a secret value: ${this.describe(t)}`, { taint: t });
628
686
  if (lt && rt) this.markReported(rt);
629
687
  } else if (node.operator === '..') {
630
688
  // Explicitly allowed by the wiki for string and number secrets.
@@ -648,9 +706,9 @@ class Analyzer {
648
706
  const t = this.taintOf(node.argument, scope);
649
707
  if (!t) return;
650
708
  if (node.operator === '#') {
651
- this.report('WSL004', node.argument, `length operator (#) on a secret value: ${this.describe(t)}`, { taint: t });
709
+ this.report(this.ruleFor('WSL004', t), node.argument, `length operator (#) on a secret value: ${this.describe(t)}`, { taint: t });
652
710
  } else if (node.operator === '-') {
653
- this.report('WSL001', node.argument, `arithmetic on a secret value: ${this.describe(t)}`, { taint: t });
711
+ this.report(this.ruleFor('WSL001', t), node.argument, `arithmetic on a secret value: ${this.describe(t)}`, { taint: t });
654
712
  } else if (node.operator === 'not') {
655
713
  this.booleanTest(node.argument, t);
656
714
  }
@@ -662,14 +720,18 @@ class Analyzer {
662
720
  if (node.type === 'IndexExpression') this.evaluate(node.index, scope);
663
721
  const baseTaint = this.taintOf(node.base, scope);
664
722
  if (baseTaint && !baseTaint.container && !baseTaint.structure) {
665
- this.report('WSL005', node, `indexed access on a secret value: ${this.describe(baseTaint)}`, {
666
- taint: baseTaint,
667
- });
723
+ if (baseTaint.category && this.guardArgDepth > 0) {
724
+ this.markUse(baseTaint, true);
725
+ } else {
726
+ this.report(this.ruleFor('WSL005', baseTaint), node, `indexed access on a secret value: ${this.describe(baseTaint)}`, {
727
+ taint: baseTaint,
728
+ });
729
+ }
668
730
  }
669
731
  if (node.type === 'IndexExpression') {
670
732
  const keyTaint = this.taintOf(node.index, scope);
671
733
  if (keyTaint) {
672
- this.report('WSL005', node.index, `secret value used as a table key: ${this.describe(keyTaint)}`, {
734
+ this.report(this.ruleFor('WSL005', keyTaint), node.index, `secret value used as a table key: ${this.describe(keyTaint)}`, {
673
735
  taint: keyTaint,
674
736
  });
675
737
  }
@@ -719,7 +781,7 @@ class Analyzer {
719
781
  const target = baseIsMethod ? node.base.base : node.base;
720
782
  const t = baseIsMethod ? null : this.taintOf(target, scope);
721
783
  if (t && !t.container) {
722
- this.report('WSL003', node.base, `call of a secret value as-if it were a function: ${this.describe(t)}`, {
784
+ this.report(this.ruleFor('WSL003', t), node.base, `call of a secret value as-if it were a function: ${this.describe(t)}`, {
723
785
  taint: t,
724
786
  });
725
787
  }
@@ -727,7 +789,17 @@ class Analyzer {
727
789
  else if (node.base.type !== 'Identifier') this.evaluate(node.base, scope);
728
790
  }
729
791
 
792
+ // AddAuraGroup and friends hand an AuraButton to their initializeFrame callback, so tag
793
+ // that parameter before the callback body is analysed, and untag it afterwards.
794
+ const buttonParams = this.patch121 ? this.tagInitializeFrameParams(callee, args) : [];
795
+ // `issecretvalue(aura.name)` indexes the aura in order to test it; that read is the
796
+ // guard idiom DBM and BigWigs ship, not a violation.
797
+ const guardCall =
798
+ callee && (this.guardShape(callee) || (!callee.method && SCRUBBERS.has(callee.name)));
799
+ if (guardCall) this.guardArgDepth += 1;
730
800
  for (const a of args) this.evaluate(a, scope);
801
+ if (guardCall) this.guardArgDepth -= 1;
802
+ for (const p of buttonParams) this.widgetOf.delete(p);
731
803
 
732
804
  if (!callee) return;
733
805
  const name = callee.name;
@@ -742,8 +814,36 @@ class Analyzer {
742
814
  }
743
815
  }
744
816
 
817
+ if (this.patch121) {
818
+ if (!callee.method) {
819
+ const removed = REMOVED_CALLS[name];
820
+ if (removed) this.report(removed.ruleId, node, removed.message);
821
+ if (name === 'CreateFrame') this.checkCreateFrameTemplates(args);
822
+ } else {
823
+ const recv = node.base && node.base.base ? this.pathOf(node.base.base) : null;
824
+ const widgetType = recv ? this.widgetOf.get(recv) : null;
825
+ const forbidden = widgetType ? FORBIDDEN_ASPECT_METHODS[widgetType][name] : null;
826
+ if (forbidden) {
827
+ this.report(
828
+ 'WSL017',
829
+ node,
830
+ `${forbidden.verb} on an ${widgetType} is disallowed by its ${forbidden.aspect} forbidden aspect: ${recv}:${name}()`
831
+ );
832
+ }
833
+ }
834
+ }
835
+
745
836
  const argTaints = args.map((a) => this.taintOf(a, scope));
746
837
 
838
+ if (this.patch121 && !callee.method && ITERATORS.has(name)) {
839
+ for (let i = 0; i < args.length; i++) {
840
+ const t = argTaints[i];
841
+ if (t && t.category) {
842
+ this.report(CATEGORY_RULES[t.category], args[i], `iteration with ${name}() over a secret value: ${this.describe(t)}`, { taint: t });
843
+ }
844
+ }
845
+ }
846
+
747
847
  if (!callee.method && SCRUBBERS.has(name)) {
748
848
  for (const t of argTaints) this.markUse(t, true);
749
849
  return;
@@ -762,6 +862,7 @@ class Analyzer {
762
862
 
763
863
  const entry = callee.method ? null : this.apiEntry(name);
764
864
  if (entry) {
865
+ if (this.patch121) this.checkRenamedStructFields(entry, args);
765
866
  const sa = entry.secretArguments;
766
867
  for (let i = 0; i < args.length; i++) {
767
868
  const t = argTaints[i];
@@ -788,6 +889,93 @@ class Analyzer {
788
889
  for (const t of argTaints) this.markUse(t, false);
789
890
  }
790
891
 
892
+ // ------------------------------------------------------------- patch 12.1 checks
893
+
894
+ /** Tag initializeFrame callback parameters as AuraButtons; returns the names to untag. */
895
+ tagInitializeFrameParams(callee, args) {
896
+ if (!callee || !callee.method || !AURA_GROUP_METHODS.has(callee.name)) return [];
897
+ const tagged = [];
898
+ for (const a of args) {
899
+ if (!a || a.type !== 'TableConstructorExpression') continue;
900
+ for (const f of a.fields) {
901
+ if (f.type !== 'TableKeyString' || f.key.name !== 'initializeFrame') continue;
902
+ if (!f.value || f.value.type !== 'FunctionDeclaration') continue;
903
+ const p = f.value.parameters && f.value.parameters[0];
904
+ if (p && p.type === 'Identifier' && !this.widgetOf.has(p.name)) {
905
+ this.widgetOf.set(p.name, 'AuraButton');
906
+ tagged.push(p.name);
907
+ }
908
+ }
909
+ }
910
+ return tagged;
911
+ }
912
+
913
+ /** WSL014: a CreateFrame template list naming the removed SecureAuraHeaderTemplate. */
914
+ checkCreateFrameTemplates(args) {
915
+ for (const a of args) {
916
+ const s = stringValue(a);
917
+ if (!s || !s.includes(REMOVED_TEMPLATE)) continue;
918
+ if (s.split(',').some((part) => part.trim() === REMOVED_TEMPLATE)) {
919
+ this.report('WSL014', a, REMOVED_TEMPLATE_MESSAGE);
920
+ }
921
+ }
922
+ }
923
+
924
+ /**
925
+ * WSL016: a field 12.1 renamed, passed inside a documented options structure. The old
926
+ * name is ignored at runtime with no Lua error, which is why it needs a static check.
927
+ */
928
+ checkRenamedStructFields(entry, args) {
929
+ const docArgs = entry.args || [];
930
+ for (let i = 0; i < args.length && i < docArgs.length; i++) {
931
+ const struct = docArgs[i] && docArgs[i].type ? this.api.structures[docArgs[i].type] : null;
932
+ if (!struct || !struct.fields) continue;
933
+ const passed =
934
+ args[i].type === 'TableConstructorExpression'
935
+ ? constructorFields(args[i])
936
+ : args[i].type === 'Identifier'
937
+ ? this.tableFields.get(args[i].name)
938
+ : null;
939
+ if (!passed) continue;
940
+ for (const [oldField, newField] of Object.entries(RENAMED_STRUCT_FIELDS)) {
941
+ if (!Object.prototype.hasOwnProperty.call(struct.fields, newField)) continue;
942
+ const keyNode = passed.get(oldField);
943
+ if (keyNode) {
944
+ this.report(
945
+ 'WSL016',
946
+ keyNode,
947
+ `${oldField} was removed from ${docArgs[i].type} in 12.1 and is silently ignored (the cooldown swipe never shows); use ${newField} instead`
948
+ );
949
+ }
950
+ }
951
+ }
952
+ }
953
+
954
+ /** Keep the path -> widget-type and path -> table-fields maps in step with assignments. */
955
+ trackPatchState(target, init) {
956
+ const path = target.type === 'Identifier' ? target.name : this.pathOf(target);
957
+ if (!path) return;
958
+ // `args.showCountdownFrame = true` after the constructor still counts as a field.
959
+ if (target.type === 'MemberExpression') {
960
+ const fields = this.tableFields.get(this.pathOf(target.base));
961
+ if (fields) fields.set(target.identifier.name, target);
962
+ }
963
+ this.widgetOf.delete(path);
964
+ this.tableFields.delete(path);
965
+ if (!init) return;
966
+ if (init.type === 'TableConstructorExpression') {
967
+ this.tableFields.set(path, constructorFields(init));
968
+ return;
969
+ }
970
+ if (init.type === 'CallExpression') {
971
+ const callee = this.calleeName(init);
972
+ if (callee && !callee.method && callee.name === 'CreateFrame') {
973
+ const frameType = stringValue(init.arguments[0]);
974
+ if (frameType && ASPECT_FRAME_TYPES.has(frameType)) this.widgetOf.set(path, frameType);
975
+ }
976
+ }
977
+ }
978
+
791
979
  crossBoundary(local, name, args, argTaints, node, scope) {
792
980
  const tainted = argTaints.map((t, i) => (t ? i : -1)).filter((i) => i >= 0);
793
981
  if (!tainted.length) return;
@@ -898,6 +1086,14 @@ class Analyzer {
898
1086
 
899
1087
  booleanTest(node, taint) {
900
1088
  if (taint.container) return;
1089
+ // The 12.1 aura APIs return a table or nil, never a boolean, and nil-checking the
1090
+ // return is the sanctioned pattern, so a boolean test on aura taint stays silent.
1091
+ // (The AuraData structure left the generated docs in 12.1, so the type lookup below
1092
+ // cannot vouch for it any more.)
1093
+ if (taint.category === 'aura') {
1094
+ this.markUse(taint, true);
1095
+ return;
1096
+ }
901
1097
  const sev = booleanTestSeverity(taint.type, this.api.structures);
902
1098
  if (!sev) {
903
1099
  this.markUse(taint, true);
@@ -907,7 +1103,7 @@ class Analyzer {
907
1103
  sev === 'error'
908
1104
  ? `its documented return type is bool`
909
1105
  : `its documented return type is unknown, so this may be a boolean secret`;
910
- this.report('WSL007', node, `boolean test on a secret value (${why}): ${this.describe(taint)}`, {
1106
+ this.report(this.ruleFor('WSL007', taint), node, `boolean test on a secret value (${why}): ${this.describe(taint)}`, {
911
1107
  severity: sev,
912
1108
  taint,
913
1109
  });
@@ -970,6 +1166,27 @@ function exits(body) {
970
1166
  return !!last && (last.type === 'ReturnStatement' || last.type === 'BreakStatement');
971
1167
  }
972
1168
 
1169
+ /** True when a literal unit token keeps this identity API non-secret (see rules.mjs). */
1170
+ function selfExempt(name, node) {
1171
+ const token = node && Array.isArray(node.arguments) ? stringValue(node.arguments[0]) : null;
1172
+ if (token === null) return false;
1173
+ if (AURA_STATE_IDENTITY_APIS.has(name)) return SELF_EXEMPT_TOKENS.has(token);
1174
+ return token === 'player';
1175
+ }
1176
+
1177
+ /** String-keyed fields of a table constructor, mapped to their key nodes. */
1178
+ function constructorFields(tableNode) {
1179
+ const fields = new Map();
1180
+ for (const f of tableNode.fields) {
1181
+ if (f.type === 'TableKeyString') fields.set(f.key.name, f.key);
1182
+ else if (f.type === 'TableKey') {
1183
+ const k = stringValue(f.key);
1184
+ if (k !== null) fields.set(k, f.key);
1185
+ }
1186
+ }
1187
+ return fields;
1188
+ }
1189
+
973
1190
  /**
974
1191
  * Analyse one Lua source file.
975
1192
  * @returns {{ findings: object[], parseError: object|null }}
@@ -981,6 +1198,7 @@ export function analyzeSource(source, filePath, api, options = {}) {
981
1198
  secretGuards: new Set(options.secretGuards ?? []),
982
1199
  accessGuards: new Set(options.accessGuards ?? []),
983
1200
  strict: options.strict === true,
1201
+ patch: options.patch ?? DEFAULT_PATCH,
984
1202
  };
985
1203
  const parseOptions = { locations: true, ranges: false, comments: false, scope: false };
986
1204
  let ast;
package/src/index.mjs CHANGED
@@ -5,14 +5,15 @@ import { relative, resolve, dirname } from 'node:path';
5
5
  import { analyzeSource } from './analyze.mjs';
6
6
  import { loadSnapshot } from './apidata.mjs';
7
7
  import { findTocFilesDeep, parseToc, resolveTocFiles, collectLuaFiles, isRetailToc, toPosix } from './toc.mjs';
8
+ import { DEFAULT_PATCH } from './rules.mjs';
8
9
 
9
- export { RULES, RULE_IDS } from './rules.mjs';
10
+ export { RULES, RULE_IDS, PATCHES, DEFAULT_PATCH } from './rules.mjs';
10
11
  export { analyzeSource } from './analyze.mjs';
11
12
  export { loadSnapshot, refreshSnapshot, writeSnapshot, extractFile, buildIndex, SNAPSHOT_PATH } from './apidata.mjs';
12
13
  export { format, formatStylish, formatJson, formatGithub, FORMATS } from './report.mjs';
13
14
  export { parseToc, findTocFiles, findTocFilesDeep, isRetailToc } from './toc.mjs';
14
15
 
15
- export const VERSION = '1.2.0';
16
+ export const VERSION = '1.3.0';
16
17
 
17
18
  /**
18
19
  * Lint an addon directory or a single .lua/.toc file.
@@ -20,6 +21,7 @@ export const VERSION = '1.2.0';
20
21
  * @param {string} target path to an addon folder, a .toc, or a .lua file
21
22
  * @param {object} options
22
23
  * @param {'retail'|'classic'} options.game
24
+ * @param {'12.0'|'12.1'} options.patch which patch surface the built-in rules check (default 12.1)
23
25
  * @param {'warn'|'error'|'off'} options.conditional how to treat conditionally secret APIs
24
26
  * @param {string[]} options.disable rule ids to silence
25
27
  * @param {string[]} options.secretGuards extra is-secret wrapper names, e.g. IsSecret
@@ -35,6 +37,7 @@ export async function lint(target, options = {}) {
35
37
  version: VERSION,
36
38
  target,
37
39
  game,
40
+ patch: options.patch ?? DEFAULT_PATCH,
38
41
  filesScanned: 0,
39
42
  findings: [],
40
43
  parseErrors: [],
@@ -121,6 +124,7 @@ export async function lint(target, options = {}) {
121
124
  secretGuards: new Set(options.secretGuards ?? []),
122
125
  accessGuards: new Set(options.accessGuards ?? []),
123
126
  strict: options.strict === true,
127
+ patch: options.patch,
124
128
  };
125
129
 
126
130
  for (const file of files) {
@@ -152,6 +156,7 @@ export function byLocation(a, b) {
152
156
  export async function lintPaths(paths, options = {}) {
153
157
  const merged = {
154
158
  version: VERSION,
159
+ patch: options.patch ?? DEFAULT_PATCH,
155
160
  filesScanned: 0,
156
161
  findings: [],
157
162
  parseErrors: [],
package/src/report.mjs CHANGED
@@ -54,6 +54,7 @@ export function formatJson(result) {
54
54
  return JSON.stringify(
55
55
  {
56
56
  version: result.version,
57
+ patch: result.patch,
57
58
  snapshot: {
58
59
  source: result.snapshot.source,
59
60
  generated: result.snapshot.generated,
package/src/rules.mjs CHANGED
@@ -3,6 +3,10 @@
3
3
 
4
4
  const WIKI = 'https://warcraft.wiki.gg/wiki/Secret_Values';
5
5
  const PATCH = 'https://warcraft.wiki.gg/wiki/Patch_12.0.0/API_changes';
6
+ const PATCH121 = 'https://warcraft.wiki.gg/wiki/Patch_12.1.0/API_changes';
7
+
8
+ export const PATCHES = ['12.0', '12.1'];
9
+ export const DEFAULT_PATCH = '12.1';
6
10
 
7
11
  export const RULES = {
8
12
  WSL001: {
@@ -60,6 +64,42 @@ export const RULES = {
60
64
  summary: 'tostring() on a secret value',
61
65
  source: `Inferred. tostring is absent from the allowed list on ${WIKI} and is not documented as forbidden either; kept a warning until confirmed in game.`,
62
66
  },
67
+ WSL012: {
68
+ severity: 'error',
69
+ patch: '12.1',
70
+ summary: 'forbidden operation on a secret aura vector',
71
+ source: `${PATCH121} : "when auras are secret (during combat, encounters, M+, and PvP matches), all of the UnitAura APIs will now either return full secrets or nil when called by addons. That means that APIs like GetUnitAuras and GetUnitAuraInstanceIDs will return a secret vector, meaning addon code will not be able to determine how many auras it contains or iterate through it for display."`,
72
+ },
73
+ WSL013: {
74
+ severity: 'error',
75
+ patch: '12.1',
76
+ summary: 'forbidden operation on a secret unit identity value',
77
+ source: `${PATCH121} : "A number of Unit APIs have been changed to return secret values when the unit's identity is secret", naming UnitClass, UnitClassBase, UnitRace, UnitSex and UnitSexBase; "UnitIsCharmed and UnitIsPossessed APIs now return secret values when auras are secret."`,
78
+ },
79
+ WSL014: {
80
+ severity: 'error',
81
+ patch: '12.1',
82
+ summary: 'symbol removed or renamed in 12.1',
83
+ source: `${PATCH121} : "UIParentLoadAddOn has been renamed to LoadAddOnWithErrorHandling." / "CanAccessObject has been replaced with FrameScriptObject:CanBeAccessedInContext." / "SecureAuraHeaderTemplate has been removed from Mainline"; C_UnitAuras.TriggerPrivateAuraShowDispelType is in the Removed list.`,
84
+ },
85
+ WSL015: {
86
+ severity: 'warning',
87
+ patch: '12.1',
88
+ summary: 'deprecated getglobal/setglobal',
89
+ source: `${PATCH121} : "Deprecated getglobal and setglobal."`,
90
+ },
91
+ WSL016: {
92
+ severity: 'warning',
93
+ patch: '12.1',
94
+ summary: 'showCountdownFrame passed to a private-aura API',
95
+ source: `${PATCH121} : showCountdownFrame was removed from AddPrivateAuraAnchorArgs and UnitPrivateAuraAnchorInfo, replaced by showCooldownFrame, showCooldownEdge and showDispelIcon. The field is silently ignored, so the cooldown swipe stops appearing with no Lua error.`,
96
+ },
97
+ WSL017: {
98
+ severity: 'error',
99
+ patch: '12.1',
100
+ summary: 'forbidden-aspect operation on an AuraButton or AuraContainer',
101
+ source: `${PATCH121} : "Aura Buttons have had the following Forbidden Aspects applied to them: UntrustedScriptExecution, UntrustedLayoutScriptExecution, AlwaysPropagateInput, ScriptedInput, and QueryFocus" / "Aura Containers have had the EventRegistrations Forbidden Aspect applied to them."`,
102
+ },
63
103
  };
64
104
 
65
105
  export const RULE_IDS = Object.keys(RULES);
@@ -137,6 +177,143 @@ const NON_BOOL_TYPES = new Set([
137
177
  'textureKit',
138
178
  ]);
139
179
 
180
+ // ------------------------------------------------------------- patch 12.1 surface
181
+ //
182
+ // None of this is in Blizzard's generated documentation. Aura secrecy is behavioural
183
+ // ("when auras are secret ... return full secrets or nil"), documented only in the 12.1
184
+ // patch notes, so the snapshot cannot carry it and these lists seed the taint instead.
185
+
186
+ /**
187
+ * APIs whose returns are a secret aura vector or secret aura data in 12.1. Taints they
188
+ * produce carry category 'aura' and report as WSL012. Bare names are listed too because
189
+ * the snapshot registers global aliases for namespaced functions.
190
+ */
191
+ export const AURA_SECRET_APIS = new Set(
192
+ [
193
+ 'C_UnitAuras.GetUnitAuras',
194
+ 'C_UnitAuras.GetUnitAuraInstanceIDs',
195
+ 'C_UnitAuras.GetAuraSlots',
196
+ 'C_UnitAuras.GetAuraDataByIndex',
197
+ 'C_UnitAuras.GetAuraDataBySlot',
198
+ 'C_UnitAuras.GetAuraDataByAuraInstanceID',
199
+ 'C_UnitAuras.GetBuffDataByIndex',
200
+ 'C_UnitAuras.GetDebuffDataByIndex',
201
+ // "all of the UnitAura APIs" per the 12.1 notes, so the by-spell lookups too.
202
+ 'C_UnitAuras.GetAuraDataBySpellName',
203
+ 'C_UnitAuras.GetUnitAuraBySpellID',
204
+ 'C_UnitAuras.GetPlayerAuraBySpellID',
205
+ 'C_UnitAuras.GetCooldownAuraBySpellID',
206
+ ].flatMap((n) => [n, n.slice(n.indexOf('.') + 1)])
207
+ );
208
+
209
+ /** Unit APIs the 12.1 notes name as returning secrets. Category 'identity', rule WSL013. */
210
+ export const IDENTITY_SECRET_APIS = new Set([
211
+ 'UnitClass',
212
+ 'UnitClassBase',
213
+ 'UnitRace',
214
+ 'UnitSex',
215
+ 'UnitSexBase',
216
+ 'UnitIsCharmed',
217
+ 'UnitIsPossessed',
218
+ ]);
219
+
220
+ /**
221
+ * Literal unit tokens that keep identity APIs non-secret. The 12.1 notes state it for
222
+ * UnitIsCharmed/UnitIsPossessed ("no longer return secret values if the unit token passed
223
+ * is 'player', 'pet', or 'vehicle'"); for the rest only "player" is exempted, because the
224
+ * change exists to stop addons comparing secret units and a unit is never secret to itself.
225
+ */
226
+ export const SELF_EXEMPT_TOKENS = new Set(['player', 'pet', 'vehicle']);
227
+ export const AURA_STATE_IDENTITY_APIS = new Set(['UnitIsCharmed', 'UnitIsPossessed']);
228
+
229
+ /** Operator findings on a categorised taint report as the 12.1 rule instead. */
230
+ export const CATEGORY_RULES = { aura: 'WSL012', identity: 'WSL013' };
231
+
232
+ /** Iterating a secret vector cannot work; flagged for categorised taints only. */
233
+ export const ITERATORS = new Set(['pairs', 'ipairs', 'next', 'unpack']);
234
+
235
+ export const AURA_SUGGESTION =
236
+ 'show auras with an AuraContainer (AddAuraGroup/AddAuraSlot) instead';
237
+
238
+ /** Calls removed, renamed or deprecated in 12.1, with the replacement each message names. */
239
+ export const REMOVED_CALLS = {
240
+ UIParentLoadAddOn: {
241
+ ruleId: 'WSL014',
242
+ message: 'UIParentLoadAddOn() was renamed in 12.1; call LoadAddOnWithErrorHandling() instead',
243
+ },
244
+ CanAccessObject: {
245
+ ruleId: 'WSL014',
246
+ message: 'CanAccessObject() was removed in 12.1; use FrameScriptObject:CanBeAccessedInContext() instead',
247
+ },
248
+ 'C_UnitAuras.TriggerPrivateAuraShowDispelType': {
249
+ ruleId: 'WSL014',
250
+ message:
251
+ 'C_UnitAuras.TriggerPrivateAuraShowDispelType() was removed in 12.1; use the showDispelIcon option on private aura anchors instead',
252
+ },
253
+ getglobal: {
254
+ ruleId: 'WSL015',
255
+ message: 'getglobal() is deprecated in 12.1; use _G[name] instead',
256
+ },
257
+ setglobal: {
258
+ ruleId: 'WSL015',
259
+ message: 'setglobal() is deprecated in 12.1; use _G[name] = value instead',
260
+ },
261
+ };
262
+
263
+ export const REMOVED_TEMPLATE = 'SecureAuraHeaderTemplate';
264
+ export const REMOVED_TEMPLATE_MESSAGE =
265
+ 'SecureAuraHeaderTemplate was removed from Mainline in 12.1; migrate to an AuraContainer (AddAuraGroup/AddAuraSlot)';
266
+
267
+ /** Structure fields renamed in 12.1 whose old name is silently ignored at runtime. */
268
+ export const RENAMED_STRUCT_FIELDS = {
269
+ showCountdownFrame: 'showCooldownFrame',
270
+ };
271
+
272
+ /** CreateFrame frame types that carry Forbidden Aspects from creation. */
273
+ export const ASPECT_FRAME_TYPES = new Set(['AuraContainer', 'AuraButton']);
274
+
275
+ /** Container methods whose options table hands AuraButtons to an initializeFrame callback. */
276
+ export const AURA_GROUP_METHODS = new Set(['AddAuraGroup', 'AddAuraSlot', 'AddItemEnchantment']);
277
+
278
+ /**
279
+ * Methods disallowed by the Forbidden Aspects each frame type carries. Buttons additionally
280
+ * get event registration flagged: they join the container's forbidden partition and the 12.1
281
+ * announcement says addons cannot "register events on those buttons".
282
+ */
283
+ const BUTTON_SCRIPTS = { aspect: 'UntrustedScriptExecution', verb: 'installing a script handler' };
284
+ const EVENT_REG = { aspect: 'EventRegistrations', verb: 'registering for events' };
285
+ const INPUT_CALL = { aspect: 'ScriptedInput', verb: 'calling an input API' };
286
+ const FOCUS_QUERY = { aspect: 'QueryFocus', verb: 'querying focus' };
287
+
288
+ export const FORBIDDEN_ASPECT_METHODS = {
289
+ AuraButton: {
290
+ SetScript: BUTTON_SCRIPTS,
291
+ HookScript: BUTTON_SCRIPTS,
292
+ RegisterEvent: EVENT_REG,
293
+ RegisterUnitEvent: EVENT_REG,
294
+ RegisterAllEvents: EVENT_REG,
295
+ Click: INPUT_CALL,
296
+ SetFocus: INPUT_CALL,
297
+ EnableMouse: INPUT_CALL,
298
+ EnableKeyboard: INPUT_CALL,
299
+ EnableMouseWheel: INPUT_CALL,
300
+ EnableGamePadButton: INPUT_CALL,
301
+ EnableGamePadStick: INPUT_CALL,
302
+ SetPropagateKeyboardInput: INPUT_CALL,
303
+ SetPassThroughButtons: INPUT_CALL,
304
+ RegisterForClicks: INPUT_CALL,
305
+ RegisterForDrag: INPUT_CALL,
306
+ IsMouseOver: FOCUS_QUERY,
307
+ IsMouseMotionFocus: FOCUS_QUERY,
308
+ HasFocus: FOCUS_QUERY,
309
+ },
310
+ AuraContainer: {
311
+ RegisterEvent: EVENT_REG,
312
+ RegisterUnitEvent: EVENT_REG,
313
+ RegisterAllEvents: EVENT_REG,
314
+ },
315
+ };
316
+
140
317
  /**
141
318
  * Decide how a boolean test on a secret should be reported.
142
319
  * Returns 'error' when the documented type is bool, null when it is a documented non-bool