-
Notifications
You must be signed in to change notification settings - Fork 0
fix(linter): match LINT-KEYWORD on identifier tokens, not raw substrings (#707) #714
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
74114dc
test: reproduce #707 — LINT-KEYWORD matches inside strings and comments
logbie f6ff1f1
fix(linter): match LINT-KEYWORD on identifier tokens, not raw substri…
logbie 2aa19be
docs: dev diary for #707 LINT-KEYWORD token-based rewrite
logbie 90bfb69
test: reproduce #707 review regression — mis-cased boolean literals l…
logbie 7d31551
fix(linter): keep LINT-KEYWORD coverage for mis-cased boolean literal…
logbie d55494a
Merge branch 'main' into fix/707-lint-keyword-token-based
logbie File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
127 changes: 127 additions & 0 deletions
127
History/dev-diary/2026/2026-08-14-issue-707-lint-keyword-on-tokens.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| # 2026-08-14 — LINT-KEYWORD was a substring search, not a lint (#707) | ||
|
|
||
| ## Symptom | ||
|
|
||
| ```wfl | ||
| store s as "MNOP" | ||
| display s | ||
| ``` | ||
|
|
||
| ```text | ||
| warning[LINT-KEYWORD]: Keyword 'NO' should be lowercase | ||
| = Change to 'no' | ||
| ``` | ||
|
|
||
| The control confirms the mechanism: `"MXYP"` reports nothing. The rule matched | ||
| the substring `NO` inside `MNOP`, inside a string literal. | ||
|
|
||
| Comments went the same way — `// Note:` produced `Keyword 'No'`, `// TODO:` | ||
| produced `Keyword 'TO'` — as did ordinary words: `"Ineligible"` produced | ||
| `Keyword 'In'`. | ||
|
|
||
| Found while porting a PHP minifier, where the program needs the character-class | ||
| constant | ||
| `"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"`. That string | ||
| contains `MNOP` and cannot be changed — it *is* the definition of `\w`. Combined | ||
| with the companion `LINT-INDENT` defect (#706), it meant `--lint` could not be | ||
| used as a gate at all. | ||
|
|
||
| ## Root cause | ||
|
|
||
| `KeywordCasingRule::apply` took the parsed `Program` and ignored it | ||
| (`_program`), then ran `source.find()` over the raw file for each of 26 | ||
| keywords, in `UPPERCASE` and `Mixedcase` forms. No tokenization, no word | ||
| boundaries, no exclusion of strings or comments. | ||
|
|
||
| The `Mixedcase` half is what made it collide with English: `No`, `To`, `In`, | ||
| `For`, `Each`, `End`, `Check`, `If`, `Count`, `From` are ordinary words and | ||
| common prefixes, so any file with prose comments was likely to trip several. | ||
|
|
||
| It was also a **false negative**. `source.find` returns only the first hit, so | ||
| the rule emitted at most one diagnostic per keyword per casing — 52 for a file | ||
| of any size. A file with fifty genuine `STORE` keywords reported one. The rule | ||
| could neither avoid reporting non-keywords nor finish reporting real ones. | ||
|
|
||
| ## The fact that shaped the fix | ||
|
|
||
| WFL keywords are **case-sensitive**: `src/lexer/token.rs:17` is | ||
| `#[token("store")]` with no `ignore(case)`. So `STORE` and `Store` never lex as | ||
| keywords — they lex as *identifiers*. `STORE s AS "x"` fails with | ||
| `Variable 'STORE s AS' is not defined`. | ||
|
|
||
| That rules out the obvious repair. "Only report where the lexer produced a | ||
| keyword token" would have reported **nothing**, silently deleting the lint while | ||
| looking like a fix. | ||
|
|
||
| The rule now flags **identifier tokens whose lowercased text is a keyword**, | ||
| which is what it was always reaching for. All four defects fall out at once: | ||
| string literals lex as string tokens, comments never reach the token stream, | ||
| `Ineligible` lowercases to a non-keyword, and iterating tokens reports every | ||
| occurrence rather than the first. | ||
|
|
||
| Keyword-ness is decided by lexing the lowercased word and checking it yields a | ||
| single non-identifier token — so the rule tracks the lexer instead of a | ||
| hardcoded list that drifts. The 26-keyword array is gone. | ||
|
|
||
| ## The mechanic that was load-bearing | ||
|
|
||
| The lexer merges adjacent identifier words into one multi-word `Identifier` | ||
| token: `STORE counter` lexes as `Identifier("STORE counter")`. Checking the | ||
| whole token text would have found no keyword — and silently deleted the lint | ||
| again, in a second way. | ||
|
|
||
| So the rule slices `byte_start..byte_end` out of the source and checks each | ||
| whitespace-separated word, offsetting the column accordingly. A multi-word | ||
| identifier cannot span lines (a newline flushes it), so the line is the token's | ||
| and the column is exact. | ||
|
|
||
| ## Red evidence | ||
|
|
||
| 5 of 10 linter tests failed against the old rule. The incidental one is the | ||
| nicest: the three-occurrence case returned two diagnostics — `STORE` once, plus | ||
| a bogus `TO` matched *inside the word* `STORE`. | ||
|
|
||
| ```text | ||
| ---- test_keyword_casing_ignores_string_literals stdout ---- | ||
| string literal contents must not be linted, got [... message: "Keyword 'NO' should be lowercase" ...] | ||
| ``` | ||
|
|
||
| Red: `74114dc`. Green: `f6ff1f1`. 10/10 after. | ||
|
|
||
| ## Known limitation, unchanged by this work | ||
|
|
||
| `--lint` runs after parsing, so a mis-cased keyword that *breaks* parsing never | ||
| reaches the linter — `STORE alpha as 1` exits 2 with a parse error. LINT-KEYWORD | ||
| therefore only fires on mis-cased words that still parse, e.g. `store Count as 5`. | ||
| That is pre-existing CLI behavior, identical before and after, and is recorded | ||
| here so the rule's real reach is not overstated. | ||
|
|
||
| ## Review round: the one token that is case-insensitive | ||
|
|
||
| Codex and Devin both caught, independently, that the first cut of this rewrite | ||
| lost coverage the old rule had. | ||
|
|
||
| `yes`/`no`/`true`/`false` are the single case-*insensitive* production in the | ||
| lexer: | ||
|
|
||
| ```rust | ||
| #[regex("(?i:yes|no|true|false)", ...)] | ||
| BooleanLiteral(bool), | ||
| ``` | ||
|
|
||
| So `store flag as YES` lexes as a `BooleanLiteral`, never as an `Identifier`, | ||
| and an identifier-only rule skipped it silently. The pre-#707 implementation | ||
| carried `yes` and `no` in its 26-keyword array and *did* warn on them — so the | ||
| claim that "output for genuine hits is identical" was false for exactly those | ||
| two keywords, in exactly the way a substring search happened to get right. | ||
|
|
||
| The rule now checks `BooleanLiteral` spans as well. Everything else in the | ||
| lexer is case-sensitive — `NothingLiteral` is `#[token("nothing")]`, so | ||
| `Nothing` still arrives as an identifier and was always covered. | ||
|
|
||
| Worth recording as a general shape: replacing a crude mechanism with a precise | ||
| one silently drops whatever the crude mechanism covered by accident. The | ||
| regression was invisible in the six tests written for the reported defect, | ||
| because those tests were all about false *positives*. | ||
|
|
||
| Red: `90bfb69`. Green: the boolean-literal branch, 12/12 after. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔍 Keyword set widened from 26 hardcoded words to the lexer's full ~181 keywords
Deriving keyword-ness from the lexer (
is_keywordatsrc/linter/mod.rs:335-338) is much broader than the removed 26-word array:src/lexer/token.rsdefines tokens for common English nouns such aslist,text,data,time,date,status,output,error,request,response,empty,test,start,new,one,any. Any capitalized word inside a variable name whose lowercase form is one of these now warns, e.g.store User Data as 5yields "Keyword 'Data' should be lowercase" with the hint "Change to 'data'" — applying that hint would turn part of an identifier into a keyword and break the program. This is the rule's intended semantics (the tests usestore Count as 5), but it materially expands the set of legitimate identifiers that now produce warnings, which matters because--lintis used as a gate. Worth confirming againstTestPrograms//examples/that no shipped program starts emitting new LINT-KEYWORD warnings.Was this helpful? React with 👍 or 👎 to provide feedback.