wow-secret-lint 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Booyaka101
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,273 @@
1
+ # wow-secret-lint
2
+
3
+ Static analysis for World of Warcraft **retail** addons. It finds Secret Value violations in your Lua before a player finds them in a red error frame.
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:
6
+
7
+ > When an operation that is not allowed is performed, the result will be an **immediate** Lua error.
8
+
9
+ The failure is invisible until it happens at runtime, in someone else's game, in a stack trace that points at a Blizzard file:
10
+
11
+ ```
12
+ ...UIWidgets/Blizzard_UIWidgetTemplateTextWithState.lua:35: attempt to perform arithmetic
13
+ on local 'textHeight' (a secret number value, while execution tainted by 'KkthnxUI')
14
+ ```
15
+
16
+ That one is [KkthnxUI#121](https://github.com/Kkthnx-Wow/KkthnxUI/issues/121), filed 2026-08-23. The identical trace was filed the same week against a completely unrelated addon, [aura-questor#68](https://github.com/lucascodev/aura-questor/issues/68). Both are in this repo's regression corpus.
17
+
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
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install --save-dev wow-secret-lint
24
+ # or run it once
25
+ npx wow-secret-lint ./MyAddon
26
+ ```
27
+
28
+ Node 20 or newer. No network access during a lint run: the API snapshot is vendored in the package.
29
+
30
+ ## Usage
31
+
32
+ Point it at an addon folder and it reads the `.toc` files to decide which Lua to analyse, in load order, following `<Script>` and `<Include>` entries in any listed `.xml`.
33
+
34
+ ```bash
35
+ npx wow-secret-lint ./MyAddon
36
+ ```
37
+
38
+ `Core/UnitFrame.lua`:
39
+
40
+ ```lua
41
+ local hp = UnitHealth("target")
42
+ local max = UnitHealthMax("target")
43
+ local pct = hp / max * 100
44
+ if hp < max then frame:Show() end
45
+ frame.text:SetText(string.format("%s hp", hp))
46
+ ```
47
+
48
+ ```
49
+ $ npx wow-secret-lint Core/UnitFrame.lua
50
+ Core/UnitFrame.lua:3:13 error WSL001 arithmetic on a secret value: 'hp' derives from UnitHealth() (SecretReturns=true)
51
+ Core/UnitFrame.lua:4:4 error WSL002 comparison of a secret value: 'hp' derives from UnitHealth() (SecretReturns=true)
52
+ 2 errors, 0 warnings
53
+ ```
54
+
55
+ Exit code 1. Line 5 is deliberately silent: the wiki says calling `string.format` with a secret is fine, so flagging it would be a false positive.
56
+
57
+ Add the guard and it goes quiet:
58
+
59
+ ```lua
60
+ if not issecretvalue(hp) and not issecretvalue(max) then
61
+ local pct = hp / max * 100
62
+ if hp < max then frame:Show() end
63
+ end
64
+ ```
65
+
66
+ ```
67
+ $ npx wow-secret-lint Core/Guarded.lua
68
+ 0 errors, 0 warnings
69
+ ```
70
+
71
+ Exit code 0.
72
+
73
+ ### Exit codes
74
+
75
+ | Code | Meaning |
76
+ | --- | --- |
77
+ | 0 | no error-severity findings |
78
+ | 1 | at least one error, or warnings above `--max-warnings` |
79
+ | 2 | a file failed to parse, or a usage/runtime failure |
80
+
81
+ ## GitHub Action
82
+
83
+ If you already run [BigWigsMods/luacheck](https://github.com/BigWigsMods/luacheck), this sits next to it. Two lines:
84
+
85
+ ```yaml
86
+ jobs:
87
+ luacheck:
88
+ runs-on: ubuntu-latest
89
+ steps:
90
+ - uses: actions/checkout@v7
91
+ - name: Luacheck linter
92
+ uses: BigWigsMods/luacheck@main
93
+ with:
94
+ args: -q
95
+ + - name: Secret value linter
96
+ + uses: Booyaka101/wow-secret-lint@v1
97
+ ```
98
+
99
+ Findings render as pull request annotations on the exact line, plus a table in the job summary.
100
+
101
+ Inputs:
102
+
103
+ | Input | Default | Description |
104
+ | --- | --- | --- |
105
+ | `path` | `.` | addon folder, `.toc`, or `.lua`. Several allowed, space separated |
106
+ | `args` | `""` | extra CLI arguments, e.g. `--conditional=warn --disable=WSL011` |
107
+ | `format` | `github` | `github`, `stylish`, or `json` |
108
+
109
+ Outputs: `errors`, `warnings`.
110
+
111
+ ## Rules
112
+
113
+ Every error-level rule maps to one sentence Blizzard publishes. `wow-secret-lint --rules` prints the table with the source for each.
114
+
115
+ | Rule | Severity | What it catches |
116
+ | --- | --- | --- |
117
+ | WSL001 | error | arithmetic on a secret value |
118
+ | WSL002 | error | relational or equality comparison of a secret value |
119
+ | WSL003 | error | calling a secret value as if it were a function |
120
+ | WSL004 | error | length operator `#` on a secret value |
121
+ | WSL005 | error | indexed access, indexed assignment, or a secret used as a table key |
122
+ | WSL006 | error | secret passed to an API whose `SecretArguments` does not allow it |
123
+ | WSL007 | error | boolean test on a secret whose documented return type is `bool` |
124
+ | WSL008 | error | registering `COMBAT_LOG_EVENT` or `COMBAT_LOG_EVENT_UNFILTERED` |
125
+ | WSL009 | warning | secret crosses into a function in this file that never guards it |
126
+ | WSL010 | warning | conditionally secret value used with no guard anywhere in scope |
127
+ | WSL011 | warning | `tostring()` on a secret value |
128
+
129
+ 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.
130
+
131
+ 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.
132
+
133
+ ### What it will never flag
134
+
135
+ 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/`:
136
+
137
+ - storing a secret in a variable, an upvalue, or as a value in a table
138
+ - passing a secret to a Lua function
139
+ - concatenating string or number secrets with `..`
140
+ - calling `string.concat`, `string.format` or `string.join` with a secret
141
+ - boolean tests on non-boolean secrets, e.g. `if UnitHealth(unit) then`
142
+
143
+ That last one is why WSL007 checks the documented return type before it fires. `UnitInRange` returns a `bool` and is `SecretReturns = true`, so `if UnitInRange(u) then` is an error. `UnitHealth` returns a `number`, so `if hp then` is silent.
144
+
145
+ ### Guards
146
+
147
+ Taint is cleared inside a branch guarded by `issecretvalue`, `canaccessvalue`, `issecrettable`, `canaccesstable` or `hasanysecretvalues`, by a frame's `HasSecretValues` / `HasSecretAspect`, and at any `scrubsecretvalues` or `secretwrap` boundary. `and`-chains, `else` branches and early returns all work:
148
+
149
+ ```lua
150
+ if issecretvalue(hp) then return end
151
+ local pct = hp / max * 100 -- silent, the early return guarded it
152
+ ```
153
+
154
+ Almost every real addon wraps the raw guards. KkthnxUI ships `IsSecret`, BigWigs ships `self:IsSecret`. Any callee whose name starts with `is`/`has`/`hasany` + `secret`, or `canaccess`, is recognised as a guard automatically. For a wrapper with an unrelated name, pass it explicitly:
155
+
156
+ ```bash
157
+ npx wow-secret-lint ./MyAddon --secret-guard=IsLocked --access-guard=CanRead
158
+ ```
159
+
160
+ ### Conditionally secret APIs
161
+
162
+ 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.
163
+
164
+ 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:
165
+
166
+ ```bash
167
+ npx wow-secret-lint ./MyAddon --conditional=warn # report as warnings
168
+ npx wow-secret-lint ./MyAddon --conditional=error # fail the build on them
169
+ ```
170
+
171
+ It is precise about structure fields. Blizzard marks safe fields `NeverSecret = true`, so this reports line 13 and stays quiet on line 10:
172
+
173
+ ```lua
174
+ local cooldown = C_Spell.GetSpellCooldown(spellID)
175
+ if not cooldown.isEnabled then -- isEnabled is NeverSecret, silent
176
+ return false
177
+ end
178
+ if cooldown.startTime ~= 0 then -- startTime is not, reported
179
+ return false
180
+ end
181
+ ```
182
+
183
+ ```
184
+ $ npx wow-secret-lint --conditional=warn Talents.lua
185
+ Talents.lua:13:5 warning WSL002 comparison of a secret value: 'cooldown.startTime' derives from C_Spell.GetSpellCooldown() (conditionally secret: SecretWhenCooldownsRestricted)
186
+ 0 errors, 1 warning
187
+ ```
188
+
189
+ That is [BtWLoadouts#67](https://github.com/Breeni/BtWLoadouts/issues/67), reduced to its shape.
190
+
191
+ ## Measured false positives
192
+
193
+ Run against 12 real retail addons at default settings (BigWigs, LittleWigs, DBM, WeakAuras, Details, SpartanUI, KkthnxUI, oUF, Bartender4, Premade Groups Filter, BtWLoadouts, AdvancedInterfaceOptions): **2,209 Lua files, 118 errors, 0 warnings**, which is 0.053 findings per file. Six of the twelve addons are affected.
194
+
195
+ Every one of the 118 was read against its source line by hand. **The false-positive count is 0.** They are overwhelmingly the same two shapes:
196
+
197
+ ```
198
+ UnitHealth(uId) / UnitHealthMax(uId) * 100 WSL001
199
+ frame:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED") WSL008
200
+ ```
201
+
202
+ Three of the twelve report nothing at all, and KkthnxUI is one of them: it already wraps its reads in `IsSecret`, and the guard heuristic sees that.
203
+
204
+ With `--conditional=warn` the same corpus produces 118 errors and 2,166 warnings (1.03 per file). That number is why the tier is opt-in, and it is the honest cost of auditing the conditional surface.
205
+
206
+ Blizzard's own `BlizzardInterfaceCode` (2,274 files) is also in the corpus as a parser stress test: **0 parse errors**. Its findings are not violations, because Blizzard's code runs untainted.
207
+
208
+ ## Configuration
209
+
210
+ ```
211
+ --format=<stylish|json|github> output format (default: stylish)
212
+ --game=<retail|classic> classic has no secret values and exits 0 immediately
213
+ --conditional=<off|warn|error> conditionally secret APIs (default: off)
214
+ --secret-guard=<names> extra is-secret wrapper functions, comma separated
215
+ --access-guard=<names> extra can-access wrapper functions, comma separated
216
+ --disable=<ids> rule ids to silence, e.g. WSL010,WSL011
217
+ --max-warnings=<n> exit 1 when warnings exceed n
218
+ --snapshot=<path> use a different API snapshot
219
+ --refresh rebuild the vendored API snapshot (the only networked command)
220
+ --rules print the rule table with its sources
221
+ ```
222
+
223
+ ## Where the data comes from
224
+
225
+ `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.
226
+
227
+ It is parsed with `luaparse`, not regexed, so nested tables and multi-line entries cannot skew it. Rebuild it any time:
228
+
229
+ ```bash
230
+ npx wow-secret-lint --refresh
231
+ ```
232
+
233
+ A scheduled workflow in this repo does that weekly and opens a pull request when the docs move.
234
+
235
+ ## Limitations and non-goals
236
+
237
+ - **No auto-fixing.** It tells you where; the fix is yours.
238
+ - **No runtime component and no in-game addon.** This is a build-time linter.
239
+ - **No Classic support.** Classic has no secret values, so `--game=classic` exits 0 immediately.
240
+ - **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.
241
+ - **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.
242
+ - **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(...)`.
243
+ - **`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(...)`.
244
+ - 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.
245
+
246
+ ## Tests
247
+
248
+ ```bash
249
+ npm test
250
+ ```
251
+
252
+ 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:
253
+
254
+ | Fixture | Issue |
255
+ | --- | --- |
256
+ | `kkthnxui-121-textheight-arithmetic` | [KkthnxUI#121](https://github.com/Kkthnx-Wow/KkthnxUI/issues/121) |
257
+ | `kkthnxui-119-map-icon-arithmetic` | [KkthnxUI#119](https://github.com/Kkthnx-Wow/KkthnxUI/issues/119) |
258
+ | `kkthnxui-118-secret-table-key` | [KkthnxUI#118](https://github.com/Kkthnx-Wow/KkthnxUI/issues/118) |
259
+ | `aura-questor-68-textheight-arithmetic` | [aura-questor#68](https://github.com/lucascodev/aura-questor/issues/68) |
260
+ | `betterfriendlist-133-missing-hassecretvalues-gate` | [BetterFriendlist#133](https://github.com/Hayato2846/BetterFriendlist/issues/133) |
261
+ | `premade-groups-filter-399-searchresult-index` | [premade-groups-filter#399](https://github.com/0xbs/premade-groups-filter/issues/399) |
262
+ | `btwloadouts-67-unguarded-cooldown-compare` | [BtWLoadouts#67](https://github.com/Breeni/BtWLoadouts/issues/67) |
263
+ | `combat-log-event-registration` | [Patch 12.0.0 API changes](https://warcraft.wiki.gg/wiki/Patch_12.0.0/API_changes) |
264
+
265
+ Each fixture reproduces the shape of the reported defect, not the exact runtime taint chain, and every one carries an `expected.json` pinning the rule id, line, severity and originating API. The header comment in each `input.lua` quotes the original trace.
266
+
267
+ ## Contributing
268
+
269
+ A false positive is a bug and worth an issue. Include the Lua, the rule id, and what the API actually returns. A rule that fires on correct code is worse than a rule that misses.
270
+
271
+ ## License
272
+
273
+ MIT
@@ -0,0 +1,141 @@
1
+ // GitHub Action entrypoint. Runs the linter in-process against the checked-out workspace
2
+ // and writes annotations plus a job summary. No install step, so src/ resolves luaparse
3
+ // from vendor/ (see src/luaparse.mjs).
4
+
5
+ import { appendFile } from 'node:fs/promises';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import process from 'node:process';
9
+ import { lintPaths, VERSION } from '../src/index.mjs';
10
+ import { format, FORMATS, counts } from '../src/report.mjs';
11
+ import { RULE_IDS } from '../src/rules.mjs';
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+
15
+ function input(name, fallback = '') {
16
+ const key = `INPUT_${name.toUpperCase().replace(/ /g, '_')}`;
17
+ const v = process.env[key];
18
+ return v === undefined || v === '' ? fallback : v;
19
+ }
20
+
21
+ function splitArgs(raw) {
22
+ const out = [];
23
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
24
+ let m;
25
+ while ((m = re.exec(raw)) !== null) out.push(m[1] ?? m[2] ?? m[3]);
26
+ return out;
27
+ }
28
+
29
+ async function setOutput(name, value) {
30
+ const file = process.env.GITHUB_OUTPUT;
31
+ if (!file) return;
32
+ await appendFile(file, `${name}=${value}\n`, 'utf8');
33
+ }
34
+
35
+ async function summary(markdown) {
36
+ const file = process.env.GITHUB_STEP_SUMMARY;
37
+ if (!file) return;
38
+ await appendFile(file, markdown, 'utf8');
39
+ }
40
+
41
+ function fail(message) {
42
+ process.stdout.write(`::error::${message.replace(/\r?\n/g, '%0A')}\n`);
43
+ process.exit(2);
44
+ }
45
+
46
+ const paths = splitArgs(input('path', '.'));
47
+ const extra = splitArgs(input('args', ''));
48
+ const fmt = input('format', 'github');
49
+
50
+ if (!FORMATS.includes(fmt)) fail(`unknown format "${fmt}" (expected one of: ${FORMATS.join(', ')})`);
51
+
52
+ const options = { conditional: 'off', disable: [], secretGuards: [], accessGuards: [], game: 'retail' };
53
+ let maxWarnings = Infinity;
54
+
55
+ for (let i = 0; i < extra.length; i++) {
56
+ const a = extra[i];
57
+ const eq = a.indexOf('=');
58
+ const key = eq === -1 ? a : a.slice(0, eq);
59
+ const val = () => (eq === -1 ? extra[++i] : a.slice(eq + 1));
60
+ switch (key) {
61
+ case '--conditional':
62
+ options.conditional = val();
63
+ break;
64
+ case '--game':
65
+ options.game = val();
66
+ break;
67
+ case '--disable':
68
+ options.disable.push(...val().split(',').map((s) => s.trim()).filter(Boolean));
69
+ break;
70
+ case '--secret-guard':
71
+ options.secretGuards.push(...val().split(',').map((s) => s.trim()).filter(Boolean));
72
+ break;
73
+ case '--access-guard':
74
+ options.accessGuards.push(...val().split(',').map((s) => s.trim()).filter(Boolean));
75
+ break;
76
+ case '--max-warnings':
77
+ maxWarnings = Number(val());
78
+ break;
79
+ case '--snapshot':
80
+ options.snapshotPath = val();
81
+ break;
82
+ default:
83
+ fail(`unknown value in "args": ${key}`);
84
+ }
85
+ }
86
+
87
+ if (!['off', 'warn', 'error'].includes(options.conditional)) {
88
+ fail(`unknown --conditional "${options.conditional}" (expected off, warn or error)`);
89
+ }
90
+ if (!['retail', 'classic'].includes(options.game)) {
91
+ fail(`unknown --game "${options.game}" (expected retail or classic)`);
92
+ }
93
+ for (const id of options.disable) {
94
+ if (!RULE_IDS.includes(id)) fail(`unknown rule id "${id}" in --disable`);
95
+ }
96
+ if (Number.isNaN(maxWarnings)) fail('--max-warnings needs a number');
97
+
98
+ if (options.game === 'classic') {
99
+ process.stdout.write('::notice::classic has no secret values; nothing to check\n');
100
+ await setOutput('errors', 0);
101
+ await setOutput('warnings', 0);
102
+ process.exit(0);
103
+ }
104
+
105
+ options.snapshotPath ??= join(HERE, '..', 'data', 'api-snapshot.json');
106
+
107
+ let merged;
108
+ try {
109
+ merged = await lintPaths(paths, options);
110
+ } catch (err) {
111
+ fail(err.message);
112
+ }
113
+
114
+ process.stdout.write(format(merged, fmt) + '\n');
115
+
116
+ const { errors, warnings } = counts(merged);
117
+ await setOutput('errors', errors);
118
+ await setOutput('warnings', warnings);
119
+
120
+ const rows = merged.findings
121
+ .slice(0, 50)
122
+ .map((f) => `| \`${f.file}:${f.line}:${f.column}\` | ${f.severity} | ${f.ruleId} | ${f.message.replace(/\|/g, '\\|')} |`)
123
+ .join('\n');
124
+
125
+ await summary(
126
+ `## wow-secret-lint ${VERSION}\n\n` +
127
+ `${merged.filesScanned} Lua file(s) scanned against ${merged.snapshot.functionCount ?? 0} documented APIs ` +
128
+ `(${merged.snapshot.secretReturnCount ?? 0} with \`SecretReturns=true\`).\n\n` +
129
+ `**${errors} error(s), ${warnings} warning(s)**` +
130
+ (merged.parseErrors.length ? `, ${merged.parseErrors.length} parse error(s)` : '') +
131
+ '\n\n' +
132
+ (rows
133
+ ? `| Location | Severity | Rule | Message |\n| --- | --- | --- | --- |\n${rows}\n` +
134
+ (merged.findings.length > 50 ? `\n_${merged.findings.length - 50} more not shown._\n` : '')
135
+ : '_No findings._\n')
136
+ );
137
+
138
+ if (merged.parseErrors.length) process.exit(2);
139
+ if (errors > 0) process.exit(1);
140
+ if (warnings > maxWarnings) process.exit(1);
141
+ process.exit(0);
package/action.yml ADDED
@@ -0,0 +1,32 @@
1
+ name: 'wow-secret-lint'
2
+ description: 'Find WoW retail addon Secret Value violations in Lua before they ship.'
3
+ author: 'Booyaka101'
4
+ branding:
5
+ icon: 'shield'
6
+ color: 'purple'
7
+
8
+ inputs:
9
+ path:
10
+ description: 'Addon folder, .toc, or .lua file to lint. Accepts several, space separated.'
11
+ required: false
12
+ default: '.'
13
+ args:
14
+ description: 'Extra CLI arguments, e.g. "--conditional=warn --disable=WSL011".'
15
+ required: false
16
+ default: ''
17
+ format:
18
+ description: 'Output format: github (annotations), stylish, or json.'
19
+ required: false
20
+ default: 'github'
21
+
22
+ outputs:
23
+ errors:
24
+ description: 'Number of error-severity findings.'
25
+ value: ${{ steps.lint.outputs.errors }}
26
+ warnings:
27
+ description: 'Number of warning-severity findings.'
28
+ value: ${{ steps.lint.outputs.warnings }}
29
+
30
+ runs:
31
+ using: 'node20'
32
+ main: 'action/index.mjs'
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ // wow-secret-lint CLI.
3
+ //
4
+ // Exit codes: 0 clean, 1 findings at error severity, 2 parse error or usage/runtime failure.
5
+
6
+ import process from 'node:process';
7
+ import { lintPaths, VERSION } from '../src/index.mjs';
8
+ import { format, FORMATS, counts } from '../src/report.mjs';
9
+ import { refreshSnapshot, writeSnapshot, SNAPSHOT_PATH } from '../src/apidata.mjs';
10
+ import { RULES, RULE_IDS } from '../src/rules.mjs';
11
+
12
+ const USAGE = `wow-secret-lint ${VERSION}
13
+ Static analysis for World of Warcraft retail addons: finds Secret Value violations
14
+ in Lua before they ship.
15
+
16
+ Usage:
17
+ wow-secret-lint [options] <path>...
18
+
19
+ <path> an addon folder (its .toc files decide the file list), a .toc, or a .lua file.
20
+
21
+ Options:
22
+ --format=<stylish|json|github> output format (default: stylish)
23
+ --game=<retail|classic> classic has no secret values and exits 0 immediately
24
+ --conditional=<off|warn|error> how to treat APIs Blizzard marks secret only under a
25
+ runtime restriction, e.g. SecretWhenCooldownsRestricted
26
+ or SecretInChatMessagingLockdown (default: off)
27
+ --secret-guard=<names> extra is-secret wrapper functions, comma separated.
28
+ Names matching is*secret/has*secret are detected already.
29
+ --access-guard=<names> extra can-access wrapper functions, comma separated
30
+ --disable=<ids> comma-separated rule ids to silence, e.g. WSL010,WSL011
31
+ --max-warnings=<n> exit 1 when warnings exceed n (default: unlimited)
32
+ --snapshot=<path> use a different API snapshot
33
+ --refresh rebuild the vendored API snapshot from the public mirror
34
+ (the only command that uses the network)
35
+ --rules print the rule table and exit
36
+ --version print the version and exit
37
+ -h, --help print this help and exit
38
+
39
+ Rules: ${RULE_IDS.join(' ')}
40
+ Docs: https://github.com/Booyaka101/wow-secret-lint
41
+ `;
42
+
43
+ function fail(message, code = 2) {
44
+ process.stderr.write(`wow-secret-lint: ${message}\n`);
45
+ process.exit(code);
46
+ }
47
+
48
+ function parseArgs(argv) {
49
+ const opts = {
50
+ format: 'stylish',
51
+ game: 'retail',
52
+ conditional: 'off',
53
+ disable: [],
54
+ secretGuards: [],
55
+ accessGuards: [],
56
+ maxWarnings: Infinity,
57
+ snapshot: undefined,
58
+ refresh: false,
59
+ rules: false,
60
+ help: false,
61
+ version: false,
62
+ paths: [],
63
+ };
64
+ for (let i = 0; i < argv.length; i++) {
65
+ const arg = argv[i];
66
+ if (arg === '--') {
67
+ opts.paths.push(...argv.slice(i + 1));
68
+ break;
69
+ }
70
+ if (arg === '-h' || arg === '--help') opts.help = true;
71
+ else if (arg === '--version' || arg === '-v') opts.version = true;
72
+ else if (arg === '--refresh') opts.refresh = true;
73
+ else if (arg === '--rules') opts.rules = true;
74
+ else if (arg.startsWith('--format')) opts.format = value(arg, argv, () => i++);
75
+ else if (arg.startsWith('--game')) opts.game = value(arg, argv, () => i++);
76
+ else if (arg.startsWith('--conditional')) opts.conditional = value(arg, argv, () => i++);
77
+ else if (arg.startsWith('--secret-guard')) opts.secretGuards.push(...value(arg, argv, () => i++).split(',').map((s) => s.trim()).filter(Boolean));
78
+ else if (arg.startsWith('--access-guard')) opts.accessGuards.push(...value(arg, argv, () => i++).split(',').map((s) => s.trim()).filter(Boolean));
79
+ else if (arg.startsWith('--disable')) opts.disable = value(arg, argv, () => i++).split(',').map((s) => s.trim()).filter(Boolean);
80
+ else if (arg.startsWith('--max-warnings')) opts.maxWarnings = Number(value(arg, argv, () => i++));
81
+ else if (arg.startsWith('--snapshot')) opts.snapshot = value(arg, argv, () => i++);
82
+ else if (arg.startsWith('-')) throw new Error(`unknown option "${arg}"`);
83
+ else opts.paths.push(arg);
84
+
85
+ function value(a, list, bump) {
86
+ const eq = a.indexOf('=');
87
+ if (eq !== -1) return a.slice(eq + 1);
88
+ bump();
89
+ const next = list[i];
90
+ if (next === undefined) throw new Error(`option "${a}" needs a value`);
91
+ return next;
92
+ }
93
+ }
94
+ return opts;
95
+ }
96
+
97
+ async function main() {
98
+ let opts;
99
+ try {
100
+ opts = parseArgs(process.argv.slice(2));
101
+ } catch (err) {
102
+ process.stderr.write(`wow-secret-lint: ${err.message}\n\n${USAGE}`);
103
+ process.exit(2);
104
+ }
105
+
106
+ if (opts.help) {
107
+ process.stdout.write(USAGE);
108
+ return 0;
109
+ }
110
+ if (opts.version) {
111
+ process.stdout.write(`${VERSION}\n`);
112
+ return 0;
113
+ }
114
+ if (opts.rules) {
115
+ for (const id of RULE_IDS) {
116
+ process.stdout.write(`${id} ${RULES[id].severity.padEnd(7)} ${RULES[id].summary}\n ${RULES[id].source}\n`);
117
+ }
118
+ return 0;
119
+ }
120
+
121
+ if (opts.refresh) {
122
+ process.stderr.write('rebuilding API snapshot from Gethe/wow-ui-source@live ...\n');
123
+ let index;
124
+ try {
125
+ let last = 0;
126
+ index = await refreshSnapshot({
127
+ onProgress: ({ done, total }) => {
128
+ if (done - last >= 50 || done === total) {
129
+ last = done;
130
+ process.stderr.write(` ${done}/${total} documentation files\n`);
131
+ }
132
+ },
133
+ });
134
+ } catch (err) {
135
+ fail(`--refresh failed: ${err.message}`);
136
+ }
137
+ const path = await writeSnapshot(index, opts.snapshot ?? SNAPSHOT_PATH);
138
+ process.stderr.write(
139
+ `wrote ${path}: ${index.functionCount} functions, ${index.secretReturnCount} with SecretReturns=true, ` +
140
+ `${index.conditionalCount} conditionally secret, ${index.structureCount} structures\n`
141
+ );
142
+ if (index.failures && index.failures.length) {
143
+ process.stderr.write(` ${index.failures.length} file(s) could not be read: ${index.failures.map((f) => f.file).join(', ')}\n`);
144
+ }
145
+ return 0;
146
+ }
147
+
148
+ if (!FORMATS.includes(opts.format)) fail(`unknown format "${opts.format}" (expected one of: ${FORMATS.join(', ')})`);
149
+ if (!['retail', 'classic'].includes(opts.game)) fail(`unknown game "${opts.game}" (expected retail or classic)`);
150
+ if (!['warn', 'error', 'off'].includes(opts.conditional)) {
151
+ fail(`unknown --conditional "${opts.conditional}" (expected warn, error or off)`);
152
+ }
153
+ for (const id of opts.disable) {
154
+ if (!RULE_IDS.includes(id)) fail(`unknown rule id "${id}" in --disable (known: ${RULE_IDS.join(', ')})`);
155
+ }
156
+ if (Number.isNaN(opts.maxWarnings)) fail('--max-warnings needs a number');
157
+ if (!opts.paths.length) {
158
+ process.stderr.write(`wow-secret-lint: no path given\n\n${USAGE}`);
159
+ process.exit(2);
160
+ }
161
+
162
+ if (opts.game === 'classic') {
163
+ if (opts.format === 'stylish') process.stdout.write('classic has no secret values; nothing to check\n');
164
+ else if (opts.format === 'github') process.stdout.write('::notice::classic has no secret values; nothing to check\n');
165
+ else process.stdout.write(`${JSON.stringify({ version: VERSION, game: 'classic', findings: [], parseErrors: [], summary: { errors: 0, warnings: 0, parseErrors: 0 } }, null, 2)}\n`);
166
+ return 0;
167
+ }
168
+
169
+ let merged;
170
+ try {
171
+ merged = await lintPaths(opts.paths, {
172
+ game: opts.game,
173
+ conditional: opts.conditional,
174
+ disable: opts.disable,
175
+ secretGuards: opts.secretGuards,
176
+ accessGuards: opts.accessGuards,
177
+ snapshotPath: opts.snapshot,
178
+ });
179
+ } catch (err) {
180
+ fail(err.message);
181
+ }
182
+
183
+ process.stdout.write(format(merged, opts.format) + '\n');
184
+
185
+ const { errors, warnings } = counts(merged);
186
+ if (merged.parseErrors.length) return 2;
187
+ if (errors > 0) return 1;
188
+ if (warnings > opts.maxWarnings) return 1;
189
+ return 0;
190
+ }
191
+
192
+ main()
193
+ .then((code) => process.exit(code))
194
+ .catch((err) => {
195
+ process.stderr.write(`wow-secret-lint: unexpected failure: ${err && err.stack ? err.stack : err}\n`);
196
+ process.exit(2);
197
+ });