wow-secret-lint 1.4.2 → 1.6.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 CHANGED
@@ -1,5 +1,124 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.0 - 2026-09-07
4
+
5
+ The four things 1.5.0 left on the table. No rule changes; the 12.0 and 12.1 baselines and
6
+ the 12-addon corpus are byte-identical to 1.5.0.
7
+
8
+ ### Added
9
+
10
+ - **`--baseline=<path>` and `--write-baseline=<path>`.** Record the findings an addon has
11
+ today, then gate CI on what is new. Entries key on file, rule and message with a count,
12
+ never on line and column, so edits above a known finding do not resurface it. Entries that
13
+ no longer match are counted and the summary says to re-record. The GitHub Action takes the
14
+ file through a new `baseline` input.
15
+ - **Widget types cross files, in `.toc` load order.** An AuraContainer, AuraButton, animation
16
+ group or protected cooldown stored in a global, in `_G[...]`, or in the addon's private table
17
+ (`local _, ns = ...` or `local ns = select(2, ...)`) is known to every file loaded after the
18
+ one that created it, under whatever local name each file gives that table. A file that
19
+ redeclares the name as a local keeps its own meaning. Separate targets on one command line
20
+ do not share widgets.
21
+ - **WSL021 sees metatable hooks.** A handler installed with
22
+ `hooksecurefunc(getmetatable(<a Cooldown>).__index, name, fn)` runs for every cooldown in
23
+ the game, so its first parameter is a cooldown that may be protected and a protected method
24
+ called on it is reported. Hooking a protected frame directly counts too. The guard the rule
25
+ asks for, `if self:IsProtected() then return end`, is recognised and clears the frame for
26
+ the rest of its block; `IsForbidden()` is not a substitute. OmniCC is clean under this
27
+ because its handlers call a proxy `Cooldown` of their own, never `self`.
28
+ - **`--version` prints a second line** naming the snapshot patch and build the run uses. The
29
+ first line is still the bare version. JSON output carries `snapshot.patch` and
30
+ `snapshot.build` too.
31
+
32
+ ### Fixed
33
+
34
+ - An early-exit guard (`if issecretvalue(x) then return end`) applied to the rest of its
35
+ block was never released when the block ended, so the path stayed cleared for the rest of
36
+ the file. No fixture and no corpus file changed output when this was fixed, but it was
37
+ wrong, and the new `IsProtected` guard would have inherited it.
38
+
39
+ ### Verified
40
+
41
+ - 249 tests. The recorded 12.0 and 12.1 baselines match byte for byte. The 12-addon corpus
42
+ reports 379 errors and 121 warnings under both `--patch=12.1` and `--patch=12.1.5`, and
43
+ 489/11 under `--strict`, all unchanged; 4,218 further files at current HEADs report nothing
44
+ from WSL019-WSL021.
45
+ - Along the way: a `local Frame = Frame` upvalue cache sent the hook-target resolver into
46
+ infinite recursion on KkthnxUI and SpartanUI, caught by the corpus run before release.
47
+
48
+ ## 1.5.0 - 2026-09-07
49
+
50
+ Patch 12.1.5 (build 69594) went live on 2026-09-03. This catches the linter up to it: the
51
+ snapshot, three new rules, and a new default `--patch` surface. `--patch=12.0` and
52
+ `--patch=12.1` produce v1.2.0's and v1.4.2's output byte for byte, which is now enforced by
53
+ recorded baselines rather than asserted.
54
+
55
+ ### Added
56
+
57
+ - **WSL019** (error): querying the progress or running state of an animation that carries
58
+ the new `QueryAnimationProgress` forbidden aspect, which *"prevents querying the progress
59
+ of an animation or whether it is running"*.
60
+ - **WSL020** (error): adding an animation to, or reparenting one onto, a group that carries
61
+ the new `AddAnimations` forbidden aspect, which *"prevents animations from being added or
62
+ reparented"*. The reparenting half is `SimpleAnim:SetParent`, where the aspect is checked
63
+ on the `parent` argument rather than on the receiver.
64
+ - **WSL021** (error): *"the `SetCooldown` and `Clear` cooldown APIs can no longer be called
65
+ from tainted code when the cooldown frame itself is protected."* Blizzard marked six
66
+ `FrameAPICooldown` methods `IsProtectedFunction = true` in 12.1.5 and none in 12.1, so the
67
+ method list is read from the snapshot rather than transcribed.
68
+ - **`--patch=12.1.5`, and it is the new default.** `PATCHES` is ordered now, so rules gate on
69
+ "at or after" a surface instead of a not-equals check against `12.0`.
70
+ - **`--patch=auto`** reads the addon's own `## Interface` number and checks the surface it
71
+ actually ships against: 120105 and up gets 12.1.5, 120100 gets 12.1, older gets 12.0. It
72
+ says so and falls back to the default when there is no `.toc` to read.
73
+ - **`--refresh-ref=<ref>`** rebuilds the snapshot from any branch or tag of the mirror, and
74
+ `--force` overrides the new guard that stops a refresh walking the snapshot backwards onto
75
+ an older client build. The mirror tags a build before it moves `live`, which is how 12.1.5
76
+ arrived here on the day it shipped; the weekly refresh workflow now reports that as a
77
+ no-op instead of a downgrade.
78
+
79
+ ### Changed
80
+
81
+ - Refreshed `data/api-snapshot.json` from `Gethe/wow-ui-source@12.1.5`: 10,250 documented
82
+ functions and 760 structures, up from 10,099 and 752, with 20 `SecretReturns` (unchanged)
83
+ and 314 conditionally secret (up from 310). Every count is regenerated, not carried over.
84
+ The new globals are in it, so WSL006 and WSL007 stop analysing 12.1.5 code against a
85
+ snapshot that has never heard of `math.clamp`, `string.startswith`, `table.keys`,
86
+ `C_Intl`, `C_Weather`, `C_Timer.NewTimedSignalMap` or `CreateFrameWithOptions`. A file
87
+ passing a secret to `math.clamp`, `C_Intl.ToUpper` and `string.startswith` reports three
88
+ WSL006 errors against this snapshot and zero against 1.4.2's, which is the whole point of
89
+ the refresh. It also brought in a new conditional marker with no code change:
90
+ `SecretWhenLuaTableHasSecretKeys` on `table.count`, `table.getcountinfo` and
91
+ `table.isempty`.
92
+ - The snapshot now carries two more Blizzard markers, keyed by widget system because method
93
+ names collide across widget types: `ChecksForbiddenAspects` (which forbidden aspect a
94
+ method checks, and on which argument) and `IsProtectedFunction`. It also records the patch,
95
+ build, commit and ref it was generated from.
96
+ - A `local` bound to a folded string constant (`"PetActionButton" .. 4 .. "Cooldown"`) now
97
+ resolves the same way a plain string literal always did, and a numeric for-loop counter
98
+ inside one folds to a digit, so `_G["ActionButton" .. i .. "Cooldown"]` is recognised in
99
+ the loop every action-bar addon writes. A local or parameter that shares a Blizzard frame's
100
+ name is never taken for the frame.
101
+ - The GitHub Action's `patch` input defaults to `12.1.5` and accepts `auto`.
102
+
103
+ ### Verified
104
+
105
+ - 230 tests pass. `--patch=12.0` and `--patch=12.1`, with and without
106
+ `--strict --conditional=warn`, reproduce v1.4.2's output over the whole fixture corpus
107
+ byte for byte, recorded before any of this work started and checked by
108
+ `test/patch1215.test.mjs`.
109
+ - On the 12-addon corpus the README measures, `--patch=12.1.5` reports the same 379 errors
110
+ and 121 warnings as `--patch=12.1`: the three new rules add nothing and take nothing away.
111
+ A second pass over each addon's current HEAD plus OmniCC, Dominos and Blizzard's own
112
+ interface code, 4,076 further files, also reports nothing from them. Four days in, nobody
113
+ has adopted the Pandemic animation APIs yet.
114
+ - One candidate finding was found and thrown out. An early draft treated a `Cooldown` created
115
+ under a secure-template button as protected, which flagged SpartanUI's quest button.
116
+ `ScriptRegion:IsProtected` says protection runs the other way: *"anchoring or parenting a
117
+ protected frame to another frame makes that frame implicitly protected as well"*, so the
118
+ child is not the one that changes. The inference was dropped and the rule now fires only
119
+ where the file names one of Blizzard's action-button cooldowns or builds a `Cooldown` from
120
+ a secure template.
121
+
3
122
  ## 1.4.2 - 2026-08-31
4
123
 
5
124
  Data only. No rule, message or analysis behaviour changed.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
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 `#`. 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, seventeen 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:
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, seventeen unit identity APIs joined them, and the new AuraButton/AuraContainer frames carry forbidden aspects. Patch 12.1.5 (build 69594, live 2026-09-03) [added two more forbidden aspects and closed the cooldown APIs](https://warcraft.wiki.gg/wiki/Patch_12.1.5/API_changes). 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.** 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.
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), 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), and the 12.1.5 rules `WSL019`/`WSL020` (the two new forbidden aspects) and `WSL021` (protected cooldown frames). 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
 
@@ -93,6 +93,64 @@ Exit code 1. Under `--patch=12.0` the same file reports zero findings and exits
93
93
 
94
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
95
 
96
+ ### The 12.1.5 surface
97
+
98
+ Patch 12.1.5 gave `CustomAuraButton` Pandemic-triggered animations, and paid for them with two new forbidden aspects: *"`QueryAnimationProgress`: prevents querying the progress of an animation or whether it is running"* and *"`AddAnimations`: prevents animations from being added or reparented."* The same notes close the cooldown APIs: *"the `SetCooldown` and `Clear` cooldown APIs can no longer be called from tainted code when the cooldown frame itself is protected."*
99
+
100
+ `Core/Pandemic.lua` builds a glow the documented way and then keeps poking it:
101
+
102
+ ```lua
103
+ local group = glow:CreateAnimationGroup()
104
+ local fade = group:CreateAnimation("Alpha")
105
+ fade:SetDuration(0.5)
106
+
107
+ button:AddPandemicActiveAnimation(group)
108
+
109
+ if group:IsPlaying() then group:Stop() end
110
+ group:CreateAnimation("Scale")
111
+ ```
112
+
113
+ `Core/Cooldowns.lua` drives one of Blizzard's action buttons:
114
+
115
+ ```lua
116
+ local cooldown = _G["ActionButton1Cooldown"]
117
+ cooldown:SetCooldown(GetTime(), 10)
118
+ ```
119
+
120
+ ```
121
+ $ npx wow-secret-lint ./PandemicGlow
122
+ Core/Cooldowns.lua:2:1 error WSL021 cooldown:SetCooldown() cannot be called from tainted code in 12.1.5 because the cooldown frame itself is protected; drive your own Cooldown frame instead, or leave the protected one to Blizzard
123
+ Core/Pandemic.lua:10:4 error WSL019 querying the progress or running state of an animation group registered with a Pandemic trigger is disallowed by its QueryAnimationProgress forbidden aspect: group:IsPlaying()
124
+ Core/Pandemic.lua:11:1 error WSL020 adding an animation to an animation group registered with a Pandemic trigger is disallowed by its AddAnimations forbidden aspect: group:CreateAnimation()
125
+ 3 errors, 0 warnings
126
+ ```
127
+
128
+ Exit code 1. The first five lines of `Core/Pandemic.lua` are Blizzard's own sample from the 12.1.5 notes and stay silent: building a group and filling it is fine, and the aspects arrive with the `AddPandemicActiveAnimation` call. `--patch=12.1` reports nothing at all in either file.
129
+
130
+ If your `.toc` is still on an older interface, `--patch=auto` reads the number and checks the surface that addon actually ships against:
131
+
132
+ ```
133
+ $ npx wow-secret-lint --patch=auto ./OldAddon # ## Interface: 120100
134
+ 0 errors, 0 warnings
135
+ ```
136
+
137
+ ### Gating CI on new findings only
138
+
139
+ Nine of the twelve addons measured below fail CI on the default settings, some with over a hundred findings. Nobody adopts a linter that way. Record what is there today and gate on what is new:
140
+
141
+ ```bash
142
+ npx wow-secret-lint ./MyAddon --write-baseline=wow-secret-lint.baseline.json
143
+ npx wow-secret-lint ./MyAddon --baseline=wow-secret-lint.baseline.json
144
+ ```
145
+
146
+ ```
147
+ $ npx wow-secret-lint --strict --baseline=baseline.json Core/UnitFrame.lua
148
+ 2 findings suppressed by baseline baseline.json
149
+ 0 errors, 0 warnings
150
+ ```
151
+
152
+ The baseline keys each finding on file, rule and message with a count, never on line and column, so editing code above a known finding does not resurface it. The message already names the value and the API it came from, which is what makes that stable. A third identical finding in a file that had two is reported; an entry that matches nothing any more is counted and the summary line says to re-record. Paths are relative to where you run, so record and lint from the same directory. Commit the file next to your `.toc`; the GitHub Action takes it through the `baseline` input.
153
+
96
154
  ### Exit codes
97
155
 
98
156
  | Code | Meaning |
@@ -126,7 +184,8 @@ Inputs:
126
184
  | Input | Default | Description |
127
185
  | --- | --- | --- |
128
186
  | `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 |
187
+ | `patch` | `12.1.5` | patch surface to check; an older value pins that rule set, `auto` reads the addon `.toc` |
188
+ | `baseline` | `""` | path to a file written with `--write-baseline`; only findings not in it are reported |
130
189
  | `args` | `""` | extra CLI arguments, e.g. `--conditional=warn --disable=WSL011` |
131
190
  | `format` | `github` | `github`, `stylish`, or `json` |
132
191
 
@@ -134,7 +193,7 @@ Outputs: `errors`, `warnings`.
134
193
 
135
194
  ## Rules
136
195
 
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, WSL017 and WSL018 fail a build by default; the rest are warnings.
196
+ Every rule maps to one sentence Blizzard publishes. `wow-secret-lint --rules` prints the table with the source for each. WSL008, WSL012, WSL013, WSL014, WSL017, WSL018, WSL019, WSL020 and WSL021 fail a build by default; the rest are warnings.
138
197
 
139
198
  | Rule | Severity | What it catches |
140
199
  | --- | --- | --- |
@@ -156,6 +215,9 @@ Every rule maps to one sentence Blizzard publishes. `wow-secret-lint --rules` pr
156
215
  | WSL016 | warning | 12.1: `showCountdownFrame` passed to a private-aura API |
157
216
  | WSL017 | error | 12.1: forbidden-aspect operation on an AuraButton or AuraContainer |
158
217
  | WSL018 | error | 12.1: call of an aura API reached by index, slot or instance id |
218
+ | WSL019 | error | 12.1.5: querying the progress or running state of an animation that carries `QueryAnimationProgress` |
219
+ | WSL020 | error | 12.1.5: adding or reparenting an animation on a group that carries `AddAnimations` |
220
+ | WSL021 | error | 12.1.5: `SetCooldown`/`Clear` on a cooldown frame Blizzard protects |
159
221
 
160
222
  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.
161
223
 
@@ -185,6 +247,37 @@ The rename is the minimal fix, but it is probably not the one you want. Reportin
185
247
 
186
248
  **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`, and it follows the value into a table field, so `self.buttons[i] = button` keeps the type. 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.
187
249
 
250
+ ### The 12.1.5 rules in detail
251
+
252
+ **WSL019** and **WSL020** are the two forbidden aspects 12.1.5 added. The notes name them outright: *"`QueryAnimationProgress`: prevents querying the progress of an animation or whether it is running"* and *"`AddAnimations`: prevents animations from being added or reparented"*. Unlike the 12.1 aura rules, this surface is in Blizzard's generated documentation, so the method lists are not transcribed by hand: every affected method carries `ChecksForbiddenAspects`, and the snapshot keeps them keyed by widget system. That is 8 methods on `SimpleAnimAPI` and 8 on `SimpleAnimGroupAPI` for `QueryAnimationProgress`, and `SimpleAnimGroupAPI:CreateAnimation` plus `SimpleAnimAPI:SetParent` for `AddAnimations`. The second of those is the "or reparented" half, and it is checked on the `parent` argument rather than on the receiver.
253
+
254
+ The aspects are applied when an animation group is registered with a Pandemic trigger, which is what the notes mean by *"two new forbidden aspects were added to support this"* and *"animations used by Pandemic triggers also gain the existing `ChangeAnimationTarget` forbidden aspect"*. Blizzard's own sample in those notes builds the group, adds its animations, and registers it last, so an addon that follows that order is silent. Everything after `AddPandemicEnterAnimation`/`AddPandemicActiveAnimation`/`AddPandemicLeaveAnimation` is not:
255
+
256
+ ```lua
257
+ local group = glow:CreateAnimationGroup()
258
+ local fade = group:CreateAnimation("Alpha") -- fine, the group is still yours
259
+ button:AddPandemicActiveAnimation(group) -- from here the client owns it
260
+
261
+ if group:IsPlaying() then end -- WSL019
262
+ group:CreateAnimation("Scale") -- WSL020
263
+ ```
264
+
265
+ Tracking follows the group through aliases, into a table field, and onto the animations that were already inside it when it was registered, and drops it the moment the local is reassigned. Guards do not silence either rule, for the same reason they do not silence WSL018: the call itself is what errors.
266
+
267
+ **WSL021** covers *"the `SetCooldown` and `Clear` cooldown APIs can no longer be called from tainted code when the cooldown frame itself is protected"*. The six methods are the ones Blizzard newly marked `IsProtectedFunction = true` on `FrameAPICooldown` in 12.1.5 and marked on none of them in 12.1: `SetCooldown`, `SetCooldownDuration`, `SetCooldownFromDurationObject`, `SetCooldownFromExpirationTime`, `SetCooldownUNIX` and `Clear`. Reading a protected cooldown is untouched, and so is a cooldown frame your addon created.
268
+
269
+ A cooldown counts as protected in two cases. It is one of Blizzard's action-button cooldowns, named directly, reached through `_G[...]` including a name built by concatenation with a numeric loop counter (`"ActionButton" .. i .. "Cooldown"` resolves), or reached as the `cooldown`/`chargeCooldown`/`lossOfControlCooldown` field of the button. A local or parameter that happens to share one of those names is left alone. The button names come from `Blizzard_ActionBar/Shared/ActionBar.lua`, which names each one `"ActionButton"..i`, `"StanceButton"..i`, `"PetActionButton"..i`, `"PossessButton"..i` or `actionBarName.."Button"..i` for the seven MultiBars, and `ActionButtonTemplate.xml` names the cooldown child `$parentCooldown` with `parentKey="cooldown"`. Or your own `Cooldown` inherits a `Secure*Template`, which is what "explicitly specified as protected at the time of creation" means for an addon.
270
+
271
+ The other way to reach a protected cooldown is not to name it at all. OmniCC and its relatives do `hooksecurefunc(getmetatable(ActionButton1Cooldown).__index, "SetCooldown", handler)`, which installs the handler on the Cooldown widget type itself, so it runs for every cooldown in the game including the protected ones. Inside such a handler the first parameter is treated as a cooldown that may be protected, and a protected method called on it is reported. `hooksecurefunc(ActionButton1Cooldown, ...)` on the frame directly counts too, `local mt = getmetatable(x).__index` is followed, and a `Cooldown` the addon created works as the metatable source, since the metatable is shared. The correct guard is recognised: `if self:IsProtected() then return end` clears the frame for the rest of the block. `IsForbidden()` does not, because forbidden and protected are different properties. OmniCC itself is clean under this, as it should be: its handlers hand the call to a proxy `Cooldown` frame of their own rather than calling `self`.
272
+
273
+ A cooldown your addon parents to a secure button is **not** protected, and that took a false positive to get right. `ScriptRegion:IsProtected` says protection travels the other way: *"anchoring or parenting a protected frame to another frame makes that frame implicitly protected as well"*, recursively, so the child is not the one that changes.
274
+
275
+ ```lua
276
+ local cd = _G["ActionButton1Cooldown"]
277
+ cd:SetCooldown(GetTime(), 10) -- WSL021
278
+ cd:IsPaused() -- silent, reading is not a protected function
279
+ ```
280
+
188
281
  ### What it will never flag
189
282
 
190
283
  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/`:
@@ -216,7 +309,7 @@ npx wow-secret-lint ./MyAddon --secret-guard=IsLocked --access-guard=CanRead
216
309
 
217
310
  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.
218
311
 
219
- 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:
312
+ From `--patch=12.1` on, the 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:
220
313
 
221
314
  ```bash
222
315
  npx wow-secret-lint ./MyAddon --conditional=warn # report as warnings
@@ -249,12 +342,13 @@ Run against 12 real retail addons (BigWigs, LittleWigs, DBM, WeakAuras, Details,
249
342
 
250
343
  | Mode | Errors | Warnings | Addons that would fail CI |
251
344
  | --- | --- | --- | --- |
252
- | `--patch=12.1` (default) | 379 | 121 | 9 of 12 |
345
+ | `--patch=12.1.5` (default) | 379 | 121 | 9 of 12 |
346
+ | `--patch=12.1` | 379 | 121 | 9 of 12 |
253
347
  | `--patch=12.1 --strict` | 489 | 11 | 9 of 12 |
254
348
  | `--patch=12.0` | 20 | 90 | 3 of 12 |
255
349
  | `--patch=12.0 --strict` | 110 | 0 | 5 of 12 |
256
350
 
257
- The two `12.0` rows are identical to what v1.2.0 reported, which is the point of the flag. Everything above them is the 12.1 surface landing, 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:
351
+ The two `12.0` rows are identical to what v1.2.0 reported, and the `12.1` rows to what v1.4.2 reported, which is the point of the flag. Everything above them is the 12.1 surface landing, 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:
258
352
 
259
353
  | Rule | Count | Where |
260
354
  | --- | --- | --- |
@@ -264,9 +358,12 @@ The two `12.0` rows are identical to what v1.2.0 reported, which is the point of
264
358
  | WSL014 | 10 | mostly `GetInventorySlotInfo`, plus `GetInspectSpecialization`, `GetWeaponEnchantInfo` and SpartanUI's `UIParentLoadAddOn` |
265
359
  | WSL016 | 3 | oUF `privateauras.lua:133` and both copies vendored into KkthnxUI and SpartanUI |
266
360
  | WSL017 | 0 | no addon in the corpus uses AuraContainers yet; exercised by fixtures |
361
+ | WSL019, WSL020, WSL021 | 0 | see below |
267
362
 
268
363
  Two numbers are worth reading together. **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. **BigWigs still has four WSL018 findings**, because guarding the result does not help when the call itself is what errors. That pair is the honest summary of where the ecosystem is: the careful addons did the guard work, and the guard work is not enough.
269
364
 
365
+ The three 12.1.5 rules report **nothing** on this corpus, and nothing on a second pass on 2026-09-07 over each addon's current HEAD plus OmniCC, Dominos and Blizzard's own interface code: 4,218 more files, zero findings. Cross-file widget typing and metatable-hook awareness, both added in 1.6.0, changed no number in either pass. Four days after the patch, no addon in reach has adopted the Pandemic animation APIs, and none of them drives Blizzard's action-button cooldowns in a way a static reader can see. OmniCC gets closest and is the honest limit of the rule: it hooks `SetCooldown` on `getmetatable(ActionButton1Cooldown).__index` and dispatches through a proxy with the method name as a string, so the frame is never named at the call site. WSL021 sees a protected cooldown only where the file names one.
366
+
270
367
  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.
271
368
 
272
369
  Contamination from Classic-flavour code is 0, checked by resolving every retail `.toc` in each repo and matching it against the findings.
@@ -295,22 +392,49 @@ Settling it needs someone to run a flagged line in game and watch what happens,
295
392
  ```
296
393
  --format=<stylish|json|github> output format (default: stylish)
297
394
  --game=<retail|classic> classic has no secret values and exits 0 immediately
298
- --patch=<12.0|12.1> patch surface the built-in rules check (default: 12.1);
299
- 12.0 pins the pre-12.1 rule set
395
+ --patch=<12.0|12.1|12.1.5|auto> patch surface the built-in rules check (default: 12.1.5);
396
+ an older value pins that rule set, auto reads the addon .toc
300
397
  --strict raise SecretReturns findings from warning to error
301
398
  --conditional=<off|warn|error> conditionally secret APIs (default: off)
302
399
  --secret-guard=<names> extra is-secret wrapper functions, comma separated
303
400
  --access-guard=<names> extra can-access wrapper functions, comma separated
304
401
  --disable=<ids> rule ids to silence, e.g. WSL010,WSL011
305
402
  --max-warnings=<n> exit 1 when warnings exceed n
403
+ --baseline=<path> report only findings not recorded in this file
404
+ --write-baseline=<path> record this run's findings to the file and exit 0
306
405
  --snapshot=<path> use a different API snapshot
307
406
  --refresh rebuild the vendored API snapshot (the only networked command)
407
+ --refresh-ref=<ref> branch or tag of the mirror to rebuild from (default: live)
408
+ --force let --refresh write an older client build than the vendored one
308
409
  --rules print the rule table with its sources
410
+ --version the version, then the snapshot patch and build it runs against
309
411
  ```
310
412
 
311
413
  ## Where the data comes from
312
414
 
313
- `data/api-snapshot.json` is built from Blizzard's generated API documentation, mirrored at [Gethe/wow-ui-source](https://github.com/Gethe/wow-ui-source) on the `live` branch. The current snapshot carries **10,098 documented functions and 752 structures**: 20 with `SecretReturns = true`, 310 conditionally secret, and per-field `NeverSecret` markers on 20 structures.
415
+ `data/api-snapshot.json` is built from Blizzard's generated API documentation, mirrored at [Gethe/wow-ui-source](https://github.com/Gethe/wow-ui-source). The current snapshot is patch **12.1.5, build 69594**, rebuilt from that mirror's `12.1.5` tag, and carries **10,250 documented functions and 760 structures**: 20 with `SecretReturns = true`, 314 conditionally secret, and per-field `NeverSecret` markers on 20 structures. It also carries the widget markers the 12.1.5 rules read: `ChecksForbiddenAspects` and `IsProtectedFunction`, keyed by widget system because method names collide across widget types.
416
+
417
+ This is the half of the release with no new rules in it and it still changes results. WSL006 and WSL007 consult the snapshot before they fire, so an addon that has already adopted a 12.1.5 API was being analysed against a documentation set that had never heard of the symbol:
418
+
419
+ ```
420
+ $ npx wow-secret-lint --strict --snapshot=./api-snapshot-1.4.2.json Adopter.lua
421
+ 0 errors, 0 warnings
422
+
423
+ $ npx wow-secret-lint --strict Adopter.lua
424
+ Adopter.lua:2:24 error WSL006 secret value passed to math.clamp(), which is documented SecretArguments = "AllowedWhenUntainted" and addon code is always tainted: 'hp' derives from UnitHealth() (SecretReturns=true)
425
+ Adopter.lua:3:29 error WSL006 secret value passed to C_Intl.ToUpper(), which is documented SecretArguments = "AllowedWhenUntainted" and addon code is always tainted: derives from UnitSpellTargetName() (SecretReturns=true)
426
+ Adopter.lua:4:22 error WSL006 secret value passed to string.startswith(), which is documented SecretArguments = "AllowedWhenUntainted" and addon code is always tainted: derives from UnitSpellTargetName() (SecretReturns=true)
427
+ 3 errors, 0 warnings
428
+ ```
429
+
430
+ The refresh also picked up a new conditional marker with no code change needed: `table.count`, `table.getcountinfo` and `table.isempty` carry `SecretWhenLuaTableHasSecretKeys`, so `--conditional=warn` reports them like any other `SecretWhen*` API.
431
+
432
+ Every count on this page comes out of the snapshot itself:
433
+
434
+ ```bash
435
+ node -p "const s=require('./data/api-snapshot.json'); [s.patch, s.build, s.functionCount, s.structureCount, s.secretReturnCount, s.conditionalCount, s.annotatedStructureCount].join(' ')"
436
+ 12.1.5 69594 10250 760 20 314 20
437
+ ```
314
438
 
315
439
  It is parsed with `luaparse`, not regexed, so nested tables and multi-line entries cannot skew it. Rebuild it any time:
316
440
 
@@ -320,7 +444,22 @@ npx wow-secret-lint --refresh
320
444
 
321
445
  A scheduled workflow in this repo does that weekly and opens a pull request when the docs move.
322
446
 
323
- 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.
447
+ The mirror tags a client build before it moves its `live` branch, which is how 12.1.5 was picked up here on the day it shipped:
448
+
449
+ ```bash
450
+ npx wow-secret-lint --refresh --refresh-ref=12.1.5
451
+ ```
452
+
453
+ The snapshot records the patch and build it came from, read off the mirror's own commit message, and `--refresh` will not walk it backwards onto an older build without `--force`. Point it at a stale ref and it says so and leaves the file alone:
454
+
455
+ ```
456
+ $ npx wow-secret-lint --refresh --refresh-ref=12.1.0
457
+ rebuilding API snapshot from Gethe/wow-ui-source@12.1.0 ...
458
+ wow-secret-lint: @12.1.0 is still 12.1.0 (build 69587), older than the vendored 12.1.5
459
+ (build 69594); left the snapshot alone. Pass --force to write it anyway.
460
+ ```
461
+
462
+ 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. The 12.1.5 rules are the other way round. Blizzard does mark those in the generated docs, so WSL019 and WSL020 read their method lists out of the snapshot and WSL021 reads the six `IsProtectedFunction` cooldown methods out of it; only the question of *which objects* carry the aspects lives in `src/rules.mjs`. There is one snapshot, and `--snapshot`/`--refresh` behave the same under every patch.
324
463
 
325
464
  ## Limitations and non-goals
326
465
 
@@ -328,9 +467,9 @@ The 12.1 aura and identity secrecy is deliberately **not** part of the snapshot:
328
467
  - **No runtime component and no in-game addon.** This is a build-time linter.
329
468
  - **No Classic support.** Classic has no secret values, so `--game=classic` exits 0 immediately.
330
469
  - **It cannot diagnose a taint-spread crash.** Some of the worst reports look like `attempt to perform arithmetic on local 'textHeight' (a secret number value, while execution tainted by 'YourAddon')` where every frame of the stack is a Blizzard file. Your addon tainted execution, and then Blizzard's code did the arithmetic. There is no forbidden operation in your Lua to point at, so this tool reports nothing. Running it over the real [aura-questor](https://github.com/lucascodev/aura-questor) source, which has exactly that open report, gives a clean run across 114 files. The regression fixture named after that issue reproduces the shape of the trace, not a finding in their code.
331
- - **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.
470
+ - **Taint does not cross files.** It 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. Widget types do cross files: see the next point.
332
471
  - **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.
333
- - **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.
472
+ - **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 and WSL019-WSL021 are the exceptions, and their 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, an animation group carries the 12.1.5 aspects only from the `AddPandemic*Animation` call that registers it, and a cooldown is protected only where the file names Blizzard's frame, builds one from a secure template, or receives one through a metatable hook. Those types do follow the addon across files, in `.toc` load order: a widget stored in a global, in `_G[...]`, or in the addon's private table (`local _, ns = ...` or `local ns = select(2, ...)`) is known to every file loaded after the one that created it, whatever local name each file gives that table. A widget passed through a function argument, returned from a function, or stored in any other table is still only known inside its own file, and a value dispatched through a proxy that takes the method name as a string is not tracked at all.
334
473
  - **XML is followed for `<Script>`/`<Include>` and checked only for removed templates.** WSL014 reads `inherits` attributes in every `.xml` the `.toc` pulls in, so a declarative aura header is caught. Nothing else in the markup is analysed: widget scripts written inline in XML are not parsed as Lua.
335
474
  - **`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(...)`.
336
475
  - 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.
@@ -341,7 +480,7 @@ The 12.1 aura and identity secrecy is deliberately **not** part of the snapshot:
341
480
  npm test
342
481
  ```
343
482
 
344
- 181 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:
483
+ 249 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 and 12.1.5 rule (`test/fixtures/rules-121/`, `test/fixtures/rules-1215/`), recorded baselines proving `--patch=12.0` reproduces the v1.2.0 output and `--patch=12.1` the v1.4.2 output byte for byte over the whole fixture corpus (`test/fixtures/patch/`), a six-file addon exercising widget typing across files in load order (`test/fixtures/cross-file/`), the `--baseline` round trip, and eight regression fixtures reconstructed from real shipped traces:
345
484
 
346
485
  | Fixture | Issue |
347
486
  | --- | --- |
package/action/index.mjs CHANGED
@@ -8,7 +8,8 @@ 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, PATCHES, DEFAULT_PATCH } from '../src/rules.mjs';
11
+ import { RULE_IDS, PATCHES, DEFAULT_PATCH, patchList } from '../src/rules.mjs';
12
+ import { applyBaselineFile } from '../src/baseline.mjs';
12
13
 
13
14
  const HERE = dirname(fileURLToPath(import.meta.url));
14
15
 
@@ -46,6 +47,7 @@ function fail(message) {
46
47
  const paths = splitArgs(input('path', '.'));
47
48
  const extra = splitArgs(input('args', ''));
48
49
  const fmt = input('format', 'github');
50
+ let baselinePath = input('baseline', '');
49
51
 
50
52
  if (!FORMATS.includes(fmt)) fail(`unknown format "${fmt}" (expected one of: ${FORMATS.join(', ')})`);
51
53
 
@@ -94,6 +96,9 @@ for (let i = 0; i < extra.length; i++) {
94
96
  case '--snapshot':
95
97
  options.snapshotPath = val();
96
98
  break;
99
+ case '--baseline':
100
+ baselinePath = val();
101
+ break;
97
102
  default:
98
103
  fail(`unknown value in "args": ${key}`);
99
104
  }
@@ -105,8 +110,8 @@ if (!['off', 'warn', 'error'].includes(options.conditional)) {
105
110
  if (!['retail', 'classic'].includes(options.game)) {
106
111
  fail(`unknown --game "${options.game}" (expected retail or classic)`);
107
112
  }
108
- if (!PATCHES.includes(options.patch)) {
109
- fail(`unknown patch "${options.patch}" (expected ${PATCHES.join(' or ')})`);
113
+ if (options.patch !== 'auto' && !PATCHES.includes(options.patch)) {
114
+ fail(`unknown patch "${options.patch}" (expected ${patchList()}, or auto)`);
110
115
  }
111
116
  for (const id of options.disable) {
112
117
  if (!RULE_IDS.includes(id)) fail(`unknown rule id "${id}" in --disable`);
@@ -129,6 +134,8 @@ try {
129
134
  fail(err.message);
130
135
  }
131
136
 
137
+ if (baselinePath) await applyBaselineFile(merged, baselinePath).catch((err) => fail(err.message));
138
+
132
139
  process.stdout.write(format(merged, fmt) + '\n');
133
140
 
134
141
  const { errors, warnings } = counts(merged);
@@ -142,10 +149,11 @@ const rows = merged.findings
142
149
 
143
150
  await summary(
144
151
  `## wow-secret-lint ${VERSION}\n\n` +
145
- `${merged.filesScanned} Lua file(s) scanned against ${merged.snapshot.functionCount ?? 0} documented APIs ` +
152
+ `${merged.filesScanned} Lua file(s) scanned on the ${merged.patch} surface against ${merged.snapshot.functionCount ?? 0} documented APIs ` +
146
153
  `(${merged.snapshot.secretReturnCount ?? 0} with \`SecretReturns=true\`).\n\n` +
147
154
  `**${errors} error(s), ${warnings} warning(s)**` +
148
155
  (merged.parseErrors.length ? `, ${merged.parseErrors.length} parse error(s)` : '') +
156
+ (merged.baseline ? `, ${merged.baseline.suppressed} suppressed by baseline` : '') +
149
157
  '\n\n' +
150
158
  (rows
151
159
  ? `| Location | Severity | Rule | Message |\n| --- | --- | --- | --- |\n${rows}\n` +
package/action.yml CHANGED
@@ -11,9 +11,13 @@ inputs:
11
11
  required: false
12
12
  default: '.'
13
13
  patch:
14
- description: 'Patch surface to check: 12.1 (default) or 12.0 for addons pinned to the older client.'
14
+ description: 'Patch surface to check: 12.1.5 (default), 12.1, 12.0, or auto to read the addon .toc.'
15
15
  required: false
16
- default: '12.1'
16
+ default: '12.1.5'
17
+ baseline:
18
+ description: 'Path to a baseline recorded with --write-baseline; only findings not in it are reported.'
19
+ required: false
20
+ default: ''
17
21
  args:
18
22
  description: 'Extra CLI arguments, e.g. "--conditional=warn --disable=WSL011".'
19
23
  required: false