Skip to content

feat(extractor): resolve const namespace identifiers and NS.KEY member access#202

Open
bdshadow wants to merge 7 commits into
mainfrom
bdshadow/resolve-const-namespace
Open

feat(extractor): resolve const namespace identifiers and NS.KEY member access#202
bdshadow wants to merge 7 commits into
mainfrom
bdshadow/resolve-const-namespace

Conversation

@bdshadow
Copy link
Copy Markdown
Member

@bdshadow bdshadow commented May 21, 2026

Problem

The extractor warns W_DYNAMIC_NAMESPACE whenever a namespace is
referenced through a constant instead of a literal:

const namespaceName = 'myNamespace';
const { t } = useTranslate(namespaceName); // ← W_DYNAMIC_NAMESPACE

const NS = { AUTH: 'auth' } as const;
const { t } = useTranslate(NS.AUTH);       // ← W_DYNAMIC_NAMESPACE

This is exactly the shape customers reach for to avoid namespace typos
(a typed constants object on top of which TypeScript catches typos).
Today, choosing safety means losing extraction. The only workarounds
are bare literals everywhere or @tolgee-key magic comments at every
call site.

Solution

A small per-file constant pre-pass runs before parsing and builds a
flat Map<string, string>:

  • const NAME = 'literal' (as const)?NAME → 'literal'
  • const NS = { K1: 'l1', K2: 'l2' } as constNS.K1 → 'l1', NS.K2 → 'l2'
  • Object-literal form supports the non-as const variant too. Nested
    objects are intentionally skipped (existing dynamic warning still
    fires).

The map is threaded through ParserContext.constants. getValue.ts
resolves bare variable tokens via the map, and a one-step lookahead
catches the variable + dot + variable (member-access) pattern.

A few additional TextMate scopes (variable.other.constant.object.ts,
variable.other.constant.property.ts, variable.other.property.ts)
are mapped to the variable customType — without that, tokens inside
useTranslate(NS.K) arrive with customType: undefined and bypass
parsing entirely.

Scope and limitations

  • Same-file only. Cross-file import { NS } from './namespaces'
    is still warned. Following imports is a fair chunk more work (module
    resolution, alias paths, ts-morph or equivalent) and explicitly out
    of scope for this PR.
  • Member access is limited to one level (NS.KEY). Deeper paths still
    warn.
  • The object-literal regex skips bodies containing nested braces.

Verification

  • npm run test:unit — 597 passed (was 583; +14 new tests).
  • Specifically:
    • test/unit/extractor/react/useTranslate.test.ts — 116 tests.
    • test/unit/extractor/react/tComponent.test.ts — 42 tests.
  • Vue / Svelte / Ngx parser suites all remain green (shared parser
    files were modified).
  • End-to-end on the companion tolgee-js testapp Namespaces.tsx:
    3 keys extracted, namespace namespaced, zero warnings.

Test plan

  • Customer pattern reproduces and warning is gone for the same-file
    shape.
  • No regression for the existing dynamic-key/dynamic-default-value
    paths.
  • Cross-file case is documented as a known limitation (PR
    description and the new extractConstants.ts doc comment).

Summary by CodeRabbit

  • New Features

    • Module-level string constants (including object-literal properties and member-access like NS.KEY) are now resolved for translation namespaces and keys, reducing dynamic/unresolvable namespace warnings.
    • Last-write-wins behavior for object-literal entries when duplicates occur.
  • Bug Fixes

    • Avoids resolving function-local consts and handles prior destructuring so top-level consts are captured correctly.
  • Tests

    • Added unit and e2e tests covering top-level const resolution and ordering-tolerant assertions.

Review Change Stack

bdshadow added 2 commits May 20, 2026 16:01
When useTranslate(NS), <T ns={NS}>, or t('key', { ns: NS }) refers to
a top-level `const NS = '...'` (optionally annotated as const), the
extractor now substitutes the literal value instead of emitting
W_DYNAMIC_NAMESPACE. Lets users keep namespaces in a shared constants
file without forcing them to inline string literals everywhere.
Builds on top of the bare-identifier resolution: extractConstants now
also flattens `const NS = { K: 'literal' } as const` (and the plain
non-`as const` form) into `NS.K -> 'literal'` entries, and
getValue consumes `variable + dot + variable` sequences to look them
up.

Also maps the TextMate scopes used for member access in argument
position (`variable.other.constant.object.ts`,
`variable.other.constant.property.ts`, `variable.other.property.ts`)
to the `variable` customType so they reach the parser at all.

Cross-file imports of constants files still warn — Path A is
same-file only.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6e47ecb3-f2e1-463f-98cf-76c70d10bec1

📥 Commits

Reviewing files that changed from the base of the PR and between 880dfe1 and b23a585.

📒 Files selected for processing (3)
  • src/extractor/parser/extractConstants.ts
  • test/e2e/push.p3.test.ts
  • test/unit/extractor/react/useTranslate.test.ts

Walkthrough

This PR adds top-level TypeScript const declaration extraction and resolution. A token-stream state machine extracts module-scope const NAME = 'literal' and flat object-literal declarations into a Map. The parser initializes the context with these constants, getValue resolves variable/member tokens against the map, and tests verify namespace resolution from declared constants.

Changes

Top-level constant namespace resolution

Layer / File(s) Summary
ParserContext constants field
src/extractor/parser/types.ts
ParserContext is extended with a constants: Map<string, string> field to hold extracted top-level const values.
Extract constants from token stream
src/extractor/parser/extractConstants.ts
Token-stream-driven state machine detects module-top-level const declarations, tracks block/function/list/object depth to control capture and abandonment, flattens object properties into NS.KEY keys, and returns a Map of bindings.
Parser integration: import and initialize constants
src/extractor/parser/parser.ts
Parser imports extractConstants and populates context.constants during parse by calling extractConstants on the filtered token stream.
Variable token type mapping
src/extractor/parser/generalMapper.ts
generalMapper switch is extended to recognize variable.other.constant.object.ts, variable.other.constant.property.ts, and variable.other.property.ts, mapping them to the variable category via fallthrough.
Variable and member-access resolution in getValue
src/extractor/parser/tree/getValue.ts
getValue adds a variable case that performs forward lookahead to detect NAME.PROP member access patterns, consumes matching tokens, and resolves both member keys and standalone variables from context.constants, returning primitive values when resolved or expr placeholders when not.
Test coverage for namespace resolution
test/unit/extractor/react/tComponent.test.ts, test/unit/extractor/react/useTranslate.test.ts
React component and useTranslate tests verify namespace resolution from top-level const declarations: plain const strings, as const exports, member access on const objects, and confirm that function-local const declarations do not shadow module-scope resolution, with expected warnings for unknown properties and undeclared identifiers.
E2E: ordering-tolerant key assertions
test/e2e/push.p3.test.ts
E2E assertions changed to compare sorted key arrays to tolerate non-deterministic API ordering when checking removed/remaining keys.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as Parser.parse
  participant Tokens as FilteredTokens
  participant Extract as extractConstants
  participant Context as context.constants
  participant getValue as getValue
  CLI->>Tokens: provide filtered token array
  CLI->>Extract: extractConstants(Tokens)
  Extract->>Context: populate Map("NAME" / "NS.KEY" -> literal)
  CLI->>getValue: resolve variable/member tokens during parse
  getValue->>Context: lookup "NAME" or "NAME.PROP"
  alt found
    Context-->>getValue: literal -> return primitive
  else not found
    Context-->>getValue: undefined -> return expr placeholder
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

released

Suggested reviewers

  • JanCizmar

Poem

🐰 I hop through tokens, ears alert and bright,
I gather consts beneath the module light.
NS.KEY whispers, plain strings find their name,
The parser learns their value, steady and tame.
Hooray — namespaces settle in the frame!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature: adding support for resolving const namespace identifiers and NS.KEY member access patterns in the extractor.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bdshadow/resolve-const-namespace

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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 34965941-3241-4bf0-bf2e-cb2cc2dfca4b

📥 Commits

Reviewing files that changed from the base of the PR and between c1652fc and 0328422.

📒 Files selected for processing (8)
  • src/extractor/extractor.ts
  • src/extractor/parser/extractConstants.ts
  • src/extractor/parser/generalMapper.ts
  • src/extractor/parser/parser.ts
  • src/extractor/parser/tree/getValue.ts
  • src/extractor/parser/types.ts
  • test/unit/extractor/react/tComponent.test.ts
  • test/unit/extractor/react/useTranslate.test.ts

Comment thread src/extractor/parser/extractConstants.ts Outdated
bdshadow added 2 commits May 23, 2026 22:12
Walks the already-merged TextMate token stream instead of regex-scanning
the raw source. Strings, comments and template literals are no longer
ambiguous (TextMate has classified them upstream), and a funcDepth
counter ensures we only capture declarations at module top level.

This addresses the scope-shadowing concern raised in PR review: a
`const NS` declared inside a function body no longer leaks into the
top-level constants map. Added a regression test pinning the behaviour.

No behavioural change for the documented happy paths; existing 249
react tests plus the new case pass.
Each sub-state of the walker now has its own function:
- stepInFunctionBody  (skip-and-track block depth)
- stepInObjectBody    (object literal property capture)
- stepAtTopLevel      (the main const-declaration FSM)
- finalizeObjectCapture / resetCapture / createContext

extractConstants() is now a small dispatch loop. No behaviour change;
all existing tests pass.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/extractor/parser/extractConstants.ts`:
- Around line 89-95: The current loop in the non-abandoned branch uses
ctx.result.has(composed) to avoid overwriting existing entries, which preserves
the first-write instead of JavaScript's last-write-wins semantics; change the
behavior in the block that iterates ctx.captureProps so that for each { key,
value } you always assign into ctx.result (e.g., call ctx.result.set(composed,
value) unconditionally) so later properties on the same captured object
(identified by ctx.captureName + '.' + key) overwrite earlier ones; keep the
check for ctx.abandonedObject as-is so this change only affects the
non-abandoned path.
- Around line 166-170: The code incorrectly treats a list.begin token like a
block by setting ctx.funcDepth = 1 (in the branch handling "c === 'block.begin'
|| c === 'list.begin'"), which relies on stepInFunctionBody() to consume the
matching end token but stepInFunctionBody() never consumes list.end; change the
logic so that list.begin is handled separately from block.begin: do not set
ctx.funcDepth when c === 'list.begin'—instead skip the array/destructure pattern
until you see the corresponding 'list.end' token (or use a short local skip
loop) and then resume with ctx.state = 'Idle'; keep the existing funcDepth
behavior only for block.begin so top-level array destructures (e.g. const [x] =
...) do not leave ctx.funcDepth > 0 and block extraction continues correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 935005c8-8f92-4862-a3dd-b7f94fb1b6ba

📥 Commits

Reviewing files that changed from the base of the PR and between 4f5c425 and 880dfe1.

📒 Files selected for processing (1)
  • src/extractor/parser/extractConstants.ts

Comment thread src/extractor/parser/extractConstants.ts
Comment thread src/extractor/parser/extractConstants.ts Outdated
bdshadow added 3 commits May 25, 2026 09:31
The finalize loop was guarding with `!result.has(composed)`, which
preserved the first-encountered value for duplicate keys within a
single `const NS = { ... }` literal. JS object-literal semantics are
last-write-wins, so the extractor's output diverged from what the
runtime actually sees. Always assign and let later properties win.

The cross-declaration guard in stepAtTopLevel (`const X = '...'`
followed by another `const X = '...'`) is unrelated and stays — a
re-declaration is a TS error anyway, and first-write is safer.
The AfterConst branch reused funcDepth for array-destructure
patterns (`const [a, b] = ...`), but stepInFunctionBody only
consumes block.begin/end. The matching list.end was therefore
ignored, leaving funcDepth permanently > 0 and silently skipping
every subsequent top-level const declaration.

Track list-bracket depth in its own listDepth counter with a
matching stepInListSkip dispatcher. Object destructure
(`const { x } = ...`) keeps the existing funcDepth path.
Regression test added.
The three `Object.keys(stored).toEqual([...])` assertions in push.p3
rely on the order in which the Tolgee API returns keys, which is not
guaranteed and varies between server versions. Compare as a set by
sorting both sides; matches the order-agnostic pattern already used in
sync.test.ts.
@bdshadow bdshadow marked this pull request as ready for review May 25, 2026 08:54
@bdshadow bdshadow requested review from JanCizmar and dkrizan May 25, 2026 08:55
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.

1 participant