Skip to content

fix(linter): match LINT-KEYWORD on identifier tokens, not raw substrings (#707) - #714

Merged
logbie merged 6 commits into
mainfrom
fix/707-lint-keyword-token-based
Aug 14, 2026
Merged

fix(linter): match LINT-KEYWORD on identifier tokens, not raw substrings (#707)#714
logbie merged 6 commits into
mainfrom
fix/707-lint-keyword-token-based

Conversation

@logbie

@logbie logbie commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #707.

LINT-KEYWORD ran source.find() over the raw file for 26 keywords in
UPPERCASE and Mixedcase forms, ignoring the parsed Program it was handed.
It therefore fired on text inside string literals, inside comments, and inside
ordinary English words:

program reported
store s as "MNOP" Keyword 'NO' should be lowercase
// Note: this explains the next step Keyword 'No' should be lowercase
store label as "Ineligible" Keyword 'In' should be lowercase

Control: store s as "MXYP" reports nothing, confirming substring matching.

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. A file with
fifty genuine STORE keywords reported one.

The fact that determined the fix

WFL keywords are case-sensitivesrc/lexer/token.rs:17 is
#[token("store")] with no ignore(case). STORE and Store do not lex as
keywords; they lex as identifiers (STORE s AS "x" fails with
Variable 'STORE s AS' is not defined).

So the obvious repair — "only report where the lexer produced a keyword token" —
would have reported nothing, silently deleting the lint while appearing to
fix it.

What the rule does now

Flags identifier tokens whose lowercased text is a keyword. 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 instead of 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 rather than a
hardcoded list that drifts. The 26-keyword array, both source.find blocks, and
the now-dead line_col_from_pos are removed. Message, hint, code and severity
wording are byte-for-byte unchanged, so output for genuine hits is identical.

One mechanic worth calling out in review: the lexer merges adjacent identifier
words into a single multi-word Identifier token (STORE counter → one
Identifier("STORE counter")). Checking the whole token text would have found
no keyword and deleted the lint a second way, so the rule slices
byte_start..byte_end from the source and checks each whitespace-separated
word, offsetting the column. A multi-word identifier cannot span lines, so the
line is the token's and the column is exact.

Test evidence

  • Risk class: R2 — --lint output and exit code are CLI behavior and a
    public contract. Backward compatibility verified: a genuine mis-cased keyword
    still warns.

  • Acceptance criteria → tests (all in src/linter/tests.rs):

    • No false positive in string literalstest_keyword_casing_ignores_string_literals (the exact repro from the issue)
    • No false positive in commentstest_keyword_casing_ignores_comments
    • No false positive inside ordinary wordstest_keyword_casing_ignores_words_containing_keywords
    • Genuine hits still reportedtest_keyword_casing_flags_uppercase_keyword (exactly 1, correct line and column)
    • Truncation fixedtest_keyword_casing_reports_every_occurrence (3 occurrences → 3 diagnostics)
    • Clean program stays cleantest_keyword_casing_clean_program
  • Red evidence: 74114dc (test-only, ancestor of the fix). 5 of 10 failed:

    ---- test_keyword_casing_ignores_string_literals stdout ----
    string literal contents must not be linted, got [... "Keyword 'NO' should be lowercase" ...]
    

    The three-occurrence case is the most telling — it returned 2: STORE
    once, plus a bogus TO matched inside the word STORE.

  • Green evidence: f6ff1f1 — 10/10.

  • Unit/component: cargo test --lib linter:: → 10 passed, 0 failed.

  • Integration/contract: cargo test --all passes, excluding two pre-existing
    flaky suites unrelated to this change (see Residual risk).

  • End-to-end: the issue's repro now lints with 0 issues, exit 0;
    store Count as 5 / store Display as 6 still yields both warnings, 2
    issues, exit 1
    .

  • Static: cargo fmt --all -- --check clean; cargo clippy --all-targets --all-features -- -D warnings clean.

  • Coverage: six cases — three false-positive classes, one true positive, the
    truncation case, and a clean program.

  • Platforms: authored and run on Windows; CI covers Ubuntu and Windows.

  • Not applicable, with reason: no Docs/ change. The only mention of the
    rule is Docs/06-best-practices/code-style-guide.md:333, the table row
    | LINT-KEYWORD | Lowercase keywords |, which remains accurate — the rule's
    contract did not change, only its matching mechanism. Nothing under Docs/
    documented the substring behavior, so there is no stale claim to correct.

  • Rollback/recovery: revert; no persistent state involved.

  • Residual risk: --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 this change, and is documented in the dev diary so the rule's
    reach is not overstated. Separately, tests/file_io_concurrent_test.rs and
    tests/file_io_performance_test.rs are flaky on this machine and also write
    output files into the repo root rather than target/test-artifacts/; both were
    confirmed pre-existing by re-running against the unmodified tree, and are
    reported separately rather than fixed here.

🤖 Generated with Claude Code


Open in Devin Review

logbie and others added 3 commits August 14, 2026 11:25
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ngs (#707)

KeywordCasingRule scanned the raw source with `source.find()` for each of 26
keywords in UPPERCASE and Mixedcase forms. That matched inside string literals
("MNOP" reported `NO`), inside comments (`// Note:` reported `No`), and inside
ordinary words ("Ineligible" reported `In`) — and because `find` returns only
the first hit, it reported at most one diagnostic per keyword per casing.

WFL keywords are case-sensitive, so a mis-cased keyword lexes as an identifier.
The rule now walks the token stream and flags identifier words whose lowercase
form is a keyword, determined by lexing that lowercase form (so the rule stays
in sync with the lexer rather than a hardcoded list). Comments and string
literals are never identifiers, whole-word matching is inherent, and every
occurrence is reported. Message, hint, code and severity are unchanged, so
genuine hits produce identical output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 14, 2026 16:40
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@logbie, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 339f7fca-a3c0-4879-bb6f-3d905d68d1fa

📥 Commits

Reviewing files that changed from the base of the PR and between 8fb784f and d55494a.

📒 Files selected for processing (3)
  • History/dev-diary/2026/2026-08-14-issue-707-lint-keyword-on-tokens.md
  • src/linter/mod.rs
  • src/linter/tests.rs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2aa19bec03

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/linter/mod.rs Outdated
Comment on lines +265 to +266
let Token::Identifier(ref name) = token.token else {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep checking case-insensitive boolean tokens

For inputs such as store answer as YES or store answer as No, the lexer emits Token::BooleanLiteral because its boolean regex is case-insensitive (src/lexer/token.rs:446-453), so this identifier-only branch skips the token entirely. The previous rule explicitly checked yes and no, meaning these valid, parseable inputs have now silently lost their LINT-KEYWORD warning despite the change's backward-compatibility claim; inspect non-lowercase boolean literal tokens before continuing.

AGENTS.md reference: AGENTS.md:L21-L21

Useful? React with 👍 / 👎.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Open in Devin Review

Comment thread src/linter/mod.rs Outdated
Comment on lines +264 to +267
for token in lex_wfl_with_positions(source) {
let Token::Identifier(ref name) = token.token else {
continue;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Wrongly capitalized yes/no values are no longer reported by the style checker

Only words the language reader treats as names are examined (Token::Identifier filter at src/linter/mod.rs:265-267), while YES, NO, Yes, No are read as true/false values, so genuinely mis-capitalized yes/no words are silently skipped.
Impact: Programs written with YES/NO instead of yes/no used to get a style warning and now get none, so the style check quietly stops covering those words.

Case-insensitive boolean literal regex keeps YES/NO out of the identifier stream

src/lexer/token.rs:446 matches booleans case-insensitively (#[regex("(?i:yes|no|true|false)")]), confirmed by src/lexer/tests.rs:615-645 where "YES", "NO", "True", "False" all lex to Token::BooleanLiteral. The new rule iterates tokens and skips anything that is not Token::Identifier, so those tokens never reach the word check. The old implementation had "yes" and "no" in its 26-keyword array (src/linter/mod.rs prior lines 273-274) and did report store flag as YES as Keyword 'YES' should be lowercase. So the PR claim that "output for genuine hits is identical" does not hold for these two keywords. A fix would additionally inspect Token::BooleanLiteral (and NothingLiteral-style literal) spans, comparing the source slice against its lowercase form.

Prompt for agents
KeywordCasingRule in src/linter/mod.rs now only inspects Token::Identifier tokens. Because src/lexer/token.rs:446 matches yes/no/true/false case-insensitively, inputs like `store flag as YES` lex to Token::BooleanLiteral and are never examined, so a genuinely mis-cased `YES`/`NO` produces no LINT-KEYWORD warning — a regression versus the removed substring implementation, which listed `yes` and `no` among its keywords. Consider also examining non-identifier tokens whose source slice (source[byte_start..byte_end]) contains uppercase characters and whose lowercase form differs, reporting them with the same message/hint, and add a regression test for `store flag as YES`.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/linter/mod.rs
Comment on lines +335 to +338
fn is_keyword(lowercase: &str) -> bool {
let tokens = lex_wfl_with_positions(lowercase);
matches!(tokens.as_slice(), [only] if !matches!(only.token, Token::Identifier(_)))
}

Copy link
Copy Markdown
Contributor

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_keyword at src/linter/mod.rs:335-338) is much broader than the removed 26-word array: src/lexer/token.rs defines tokens for common English nouns such as list, 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 5 yields "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 use store Count as 5), but it materially expands the set of legitimate identifiers that now produce warnings, which matters because --lint is used as a gate. Worth confirming against TestPrograms//examples/ that no shipped program starts emitting new LINT-KEYWORD warnings.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes LINT-KEYWORD false positives/negatives by switching from raw source.find() substring matching to token-based inspection, aligning the lint with what the lexer actually recognizes and allowing it to report every occurrence.

Changes:

  • Reimplemented KeywordCasingRule to scan lexer tokens (identifiers) and detect mis-cased keywords via lowercasing + re-lexing, with correct per-occurrence positioning.
  • Added focused regression tests for the issue #707 cases (strings/comments/ordinary words) plus multi-occurrence reporting.
  • Added a dev-diary entry documenting the bug, the root cause, and the rationale behind the new approach.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/linter/mod.rs Reworks LINT-KEYWORD implementation to operate on lexer output (and adds helper utilities).
src/linter/tests.rs Adds targeted unit tests covering #707 regressions and multi-occurrence reporting.
History/dev-diary/2026/2026-08-14-issue-707-lint-keyword-on-tokens.md Documents the defect and the design constraints that shaped the fix.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/linter/tests.rs
Comment on lines +87 to +103
/// A genuinely mis-cased keyword must still be reported (backward compatibility).
#[test]
fn test_keyword_casing_flags_uppercase_keyword() {
let diagnostics = keyword_casing_diagnostics("STORE counter as 5");

assert_eq!(diagnostics.len(), 1, "got {diagnostics:?}");
assert_eq!(diagnostics[0].code, "LINT-KEYWORD");
assert_eq!(diagnostics[0].severity, Severity::Warning);
assert_eq!(
diagnostics[0].message,
"Keyword 'STORE' should be lowercase"
);
assert_eq!(diagnostics[0].notes, vec!["Change to 'store'".to_string()]);
assert_eq!(diagnostics[0].line, 1);
assert_eq!(diagnostics[0].column, 1);
}

Comment thread src/linter/mod.rs
Comment on lines +264 to +277
for token in lex_wfl_with_positions(source) {
let Token::Identifier(ref name) = token.token else {
continue;
};

// The lexer merges adjacent identifier words into one multi-word
// identifier, so check each word separately. Words are ASCII and the
// separators are spaces/tabs, so the byte offset within the token's
// span is also the column offset. Multi-word identifiers never span
// lines (a newline flushes them), so the line is the token's line.
let text = source
.get(token.byte_start..token.byte_end)
.unwrap_or(name.as_str());

Copilot AI review requested due to automatic review settings August 14, 2026 17:06
@logbie

logbie commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Review findings — all addressed

@chatgpt-codex-connector and @devin-ai-integration — confirmed and fixed. Both of you independently found a real regression, and you were right.

yes|no|true|false is the only case-insensitive production in the lexer (src/lexer/token.rs:446, #[regex("(?i:yes|no|true|false)")]), so store flag as YES lexes as Token::BooleanLiteral and never reached the identifier branch. The pre-#707 rule carried yes and no in its 26-keyword array and did warn on them — so this PR's claim that "output for genuine hits is identical" was false for exactly those two keywords. That claim has been corrected.

Fixed in 7d31551: the rule now also checks BooleanLiteral spans. Red evidence first in 90bfb69store flag as YES reported 0 diagnostics where 1 was expected.

I checked whether anything else was affected: BooleanLiteral is the only case-insensitive token. NothingLiteral is #[token("nothing")] / #[token("missing")] / #[token("undefined")] — case-sensitive — so Nothing still lexes as an identifier and was covered all along. Coverage is now a superset of the old rule's, with none of the false positives.

New tests: test_keyword_casing_flags_mis_cased_boolean_literals (all four literals, in YES / No / TRUE / False forms) and test_keyword_casing_accepts_lowercase_boolean_literals. Linter module is 12/12.

Worth naming the general shape, since it is the interesting part of this review: replacing a crude mechanism with a precise one silently drops whatever the crude mechanism covered by accident. Every test written for the reported defect was about false positives, so none of them could have caught a lost true positive. Recorded in the dev diary.

@chatgpt-codex-connector

Copy link
Copy Markdown

Summary

  • Verified that commit 7d31551 addresses the review regression by checking both identifier tokens and case-insensitive BooleanLiteral token spans while preserving the existing diagnostic wording and positions. src/linter/mod.rsL264-L310
  • Confirmed regression coverage for YES, No, TRUE, and False, along with negative coverage for correctly lowercase boolean literals. src/linter/tests.rsL157-L186
  • Confirmed the dev diary records the root cause, Red commit 90bfb69, Green result, and the distinction between case-insensitive boolean literals and case-sensitive nothing literals. History/dev-diary/2026/2026-08-14-issue-707-lint-keyword-on-tokens.mdL99-L127
  • No further code changes were necessary; the working tree remains clean. Therefore, no additional commit or follow-up pull request was created.

Testing

  • cargo test --lib linter:: — 12 passed, 0 failed, 0 ignored.
  • git status --short --branch — clean working tree on branch work.

View task →

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/linter/mod.rs:298

  • keyword_cache.entry(lowercase.clone()) clones/allocates the lowercased string even on cache hits, doubling work for files with many repeated identifiers (e.g. many STORE occurrences). You can avoid the extra clone by checking get() first and only cloning when inserting a new key.
                let lowercase = word.to_lowercase();
                let is_keyword = *keyword_cache
                    .entry(lowercase.clone())
                    .or_insert_with(|| is_keyword(&lowercase));

src/linter/tests.rs:103

  • The rule’s new token.column + offset calculation is load-bearing for multi-word identifiers, but the tests don’t currently cover the case where the mis-cased keyword appears later in the identifier (to validate the offset math). Adding a regression that asserts the reported column for store My Store as 5 would protect against future changes to identifier merging/whitespace handling.
/// A genuinely mis-cased keyword must still be reported (backward compatibility).
#[test]
fn test_keyword_casing_flags_uppercase_keyword() {
    let diagnostics = keyword_casing_diagnostics("STORE counter as 5");

Copilot AI review requested due to automatic review settings August 14, 2026 17:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/linter/mod.rs:287

  • Token::Identifier falls back to name.as_str() if slicing the original source span fails. That fallback can produce incorrect word offsets (and therefore incorrect diagnostic columns) because the linter later adds byte offsets computed from the sliced text. Since diagnostics depend on exact source positions, it’s safer and consistent with the BooleanLiteral arm to skip the token when the slice can’t be recovered.
            let text = match token.token {
                Token::Identifier(ref name) => source
                    .get(token.byte_start..token.byte_end)
                    .unwrap_or(name.as_str()),

@logbie
logbie merged commit ea5ecb1 into main Aug 14, 2026
20 checks passed
@logbie
logbie deleted the fix/707-lint-keyword-token-based branch August 14, 2026 17:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LINT-KEYWORD substring-matches inside strings and comments (// Note: warns "Keyword No"), and reports only the first hit per keyword

2 participants