Skip to content

chore(FR-3028): add ESLint rule forbidding fragment $key type at its spread site - #9518

Draft
yomybaby wants to merge 3 commits into
mainfrom
chore/FR-3028-eslint-no-fragment-key-at-spread-site
Draft

chore(FR-3028): add ESLint rule forbidding fragment $key type at its spread site#9518
yomybaby wants to merge 3 commits into
mainfrom
chore/FR-3028-eslint-no-fragment-key-at-spread-site

Conversation

@yomybaby

@yomybaby yomybaby commented Sep 7, 2026

Copy link
Copy Markdown
Member

Resolves #7691 (FR-3028)

Summary

Adds a custom bai/no-fragment-key-at-spread-site ESLint rule to eslint-config-bai, registered at error for **/*.{ts,tsx} in the shared react config, so both react/ and packages/backend.ai-ui/ pick it up.

The rule flags typing a local value (a useState cell or a plain variable annotation) with a Relay fragment's generated $key type at a site that already spreads that fragment in a graphql literal. When a component spreads ...Foo in its own query/fragment, the response already carries $fragmentSpreads and is assignable to Foo$key — so importing the child's $key there only adds type coupling to the child. The type should be derived from the local query node instead.

This is a nice-to-have project convention, not a Relay-mandated pattern.

Exemptions (intentionally not flagged)

  • fragment-owning files that define fragment Foo on ... — they legitimately use Foo$key for their prop type and/or useFragment<Foo$key>.
  • prop / callback-parameter positions — the standard fragment-component prop contract (function-type parameters are deliberately not walked).
  • files that never spread ...Foo (the ref is sourced from a prop elsewhere).
  • ... on Type inline fragments, and JS object/array spreads outside a graphql literal.

Design decisions

  • Textual scan of the graphql literal, not the whole source. Relay literals in this repo carry no ${} interpolation, so concatenating the quasis gives the complete document — and scanning the literal rather than the file is what keeps JS spread operators and doc strings from being mistaken for fragment spreads.
  • Name-based matching, no type-aware linting — keeps the rule cheap enough to run on every file.
  • The rule's tests run in the root vitest project: eslint-config-bai is plain ESM with no build step and no vitest config of its own, so vitest.config.ts now includes packages/eslint-config-bai/**/*.test.js (and the packages/** exclude carves that one package out). .github/workflows/vitest-root.yml gained the matching path filter.

Fixed violations (12, across 11 files)

Replaced useState<X$key> with the local query node type and dropped the now-unused $key import:

  • react/src/components/: AdminUserCredentialList, BulkCreateUserFromCSVModal, DeploymentReplicasCard, KeypairResourcePolicyList (2), ProjectResourcePolicyList, ResourcePresetList, UserResourcePolicyList, UserSettingModal
  • react/src/pages/: DeploymentListPage, ProjectAdminDeploymentsPage, ReservoirArtifactDetailPage

Plural fragment refs (UserSettingModal, BulkCreateUserFromCSVModal, ReservoirArtifactDetailPage) use ReadonlyArray<Node>. DeploymentReplicasCard also drops an as DeploymentRevisionDetail_revision$key cast that the derived type makes unnecessary. Type annotations and imports only — no runtime behaviour changes.

Credit

This revives #7692 by @nowgnuesLee, which was closed unmerged on 2026-07-27. The rule is his; the call-site fixes are re-derived against current main (two of his targets have since been deleted, one was renamed DeploymentReplicasTabDeploymentReplicasCard, and BulkCreateUserFromCSVModal newly matches). The RuleTester suite is new.

Tests

  • pnpm exec vitest run packages/eslint-config-bai/bai-plugin.test.js → 14 passed (8 valid cases covering each exemption plus the comment / string / escaped-block-string scan cases, 6 invalid covering useState, React.useState, plural refs, plain variable annotations, a file that defines a different fragment, and a fragment Foo on ... that appears only in prose).
  • pnpm exec vitest run (root suite) → 8 files, 171 passed / 6 skipped — confirms the new file is picked up by the root project.
  • pnpm exec eslint src in react/ and in packages/backend.ai-ui/0 bai/no-fragment-key-at-spread-site violations remaining, 0 eslint errors total.

Verification

bash scripts/verify.sh=== ALL PASS ===

(Relay / Lint script coverage / Lint / Format / TypeScript / TypeScript (agent-cli) / Vite warmup paths / StyleX cssInjectionTarget / Astryx theme build / Astryx integration / z-index ladder mirrors / Agent mappings / Terminology — all PASS.)

No CSS or theme tokens touched, so the Astryx token gate is not implicated.

Review notes

  • Does the rule fire where it should, and stay quiet where it shouldn't? packages/eslint-config-bai/bai-plugin.test.js is the fastest read — the valid array is the exemption list, one case each.
  • Is any call-site fix a behaviour change? Every hunk is a type annotation plus a dropped import; the one exception is DeploymentReplicasCard.tsx:507, where a now-redundant as ...$key cast is removed from the <Link onClick> handler. tsc (in verify.sh) is the real gate here.
  • Is the vitest wiring right? vitest.config.ts changes packages/**packages/!(eslint-config-bai)/** in exclude; the other three package suites (backend.ai-ui, backend.ai-agent-cli, backend.ai-client) keep their own configs and workflows untouched.

Checklist: (if applicable)

  • Documentation
  • Minium required manager version
  • Specific setting for review (eg., KB link, endpoint or how to setup)
  • Minimum requirements to check during review — bash scripts/verify.sh ends with === ALL PASS ===
  • Test case(s) to demonstrate the difference of before/after — packages/eslint-config-bai/bai-plugin.test.js

https://claude.ai/code/session_011RFmSEBxxxvCXyquSPtqVJ

…spread site

Add `bai/no-fragment-key-at-spread-site` to eslint-config-bai, registered at
`error` for `**/*.{ts,tsx}` in the shared `react` config so both `react/` and
`packages/backend.ai-ui/` pick it up.

The rule flags typing a local value (a `useState` cell or a plain variable)
with a fragment's generated `$key` at a site that already spreads that fragment
in a `graphql` literal. The local query response already carries
`$fragmentSpreads` and is assignable to `Foo$key`, so importing the child's
`$key` only adds type coupling to the child; the type should be derived from
the local query node instead.

Exempt: the fragment-owning file (it defines the fragment), prop and callback
parameter positions (the fragment-component prop contract), and files that
never spread the fragment.

Fixes the 12 violations the rule reports on main by deriving local query node
types and dropping the now-unused `$key` imports. Type annotations only — no
runtime behaviour changes.

Reviving PR #7692 by nowgnuesLee, which was closed unmerged; the rule and the
call-site fixes are re-derived against current main, and the rule now has a
RuleTester suite in the root vitest project.

Co-Authored-By: Seungwon Lee <seungwon@lablup.com>
Claude-Session: https://claude.ai/code/session_011RFmSEBxxxvCXyquSPtqVJ
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for root-coverage

Status Category Percentage Covered / Total
🔵 Lines 18.79% 28 / 149
🔵 Statements 20% 32 / 160
🔵 Functions 37.5% 6 / 16
🔵 Branches 26.47% 18 / 68
File CoverageNo changed files found.
Generated in workflow #139 for commit d079dbd by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for react-coverage (./react)

Status Category Percentage Covered / Total
🔵 Lines 15.63% 5691 / 36394
🔵 Statements 13.11% 6933 / 52866
🔵 Functions 13.68% 887 / 6482
🔵 Branches 9.48% 4795 / 50563
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
react/src/components/AdminUserCredentialList.tsx 0% 0% 0% 0% 54-579
react/src/components/BulkCreateUserFromCSVModal.tsx 0% 0% 0% 0% 106-1411
react/src/components/DeploymentReplicasCard.tsx 0% 0% 0% 0% 77-640
react/src/components/KeypairResourcePolicyList.tsx 0% 0% 0% 0% 53-453
react/src/components/ProjectResourcePolicyList.tsx 0% 0% 0% 0% 49-317
react/src/components/ResourcePresetList.tsx 0% 0% 0% 0% 39-152
react/src/components/UserResourcePolicyList.tsx 0% 0% 0% 0% 46-318
react/src/components/UserSettingModal.tsx 0% 0% 0% 0% 77-200
react/src/pages/DeploymentListPage.tsx 0% 0% 0% 0% 73-104
react/src/pages/ProjectAdminDeploymentsPage.tsx 0% 0% 0% 0% 71-102
react/src/pages/ReservoirArtifactDetailPage.tsx 0% 0% 0% 0% 57-765
Generated in workflow #751 for commit d079dbd by the Vitest Coverage Report Action

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.

🟡 Changes recommended

Four moderate rule-correctness and coverage issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a shared ESLint rule forbidding Relay fragment $key types at local fragment spread sites and updates existing violations.

Changes:

  • Adds and enables bai/no-fragment-key-at-spread-site.
  • Adds RuleTester and CI coverage.
  • Replaces affected $key annotations with locally derived query types.

Required fixes:

  • Moderate (2 votes): Parse or tokenize GraphQL literals to avoid comment/string false positives and false negatives.
  • Moderate (2 votes): Track generated GraphQL imports and aliases instead of matching identifier spelling alone.
  • Moderate (1 vote): Traverse object type members so nested state shapes are checked.
  • Moderate (1 vote): Recognize React.useState and add corresponding test coverage.
File summaries
File Description
vitest.config.ts Includes ESLint rule tests in the root suite.
react/src/pages/ReservoirArtifactDetailPage.tsx Uses locally derived revision node types.
react/src/pages/ProjectAdminDeploymentsPage.tsx Derives revision state locally.
react/src/pages/DeploymentListPage.tsx Derives revision state locally.
react/src/components/UserSettingModal.tsx Derives created-keypair state locally.
react/src/components/UserResourcePolicyList.tsx Derives policy state locally.
react/src/components/ResourcePresetList.tsx Derives preset state locally.
react/src/components/ProjectResourcePolicyList.tsx Derives policy state locally.
react/src/components/KeypairResourcePolicyList.tsx Derives policy state types locally.
react/src/components/DeploymentReplicasCard.tsx Derives revision state and removes a cast.
react/src/components/BulkCreateUserFromCSVModal.tsx Derives created-keypair state locally.
react/src/components/AdminUserCredentialList.tsx Derives keypair state locally.
packages/eslint-config-bai/react.js Enables the new rule for TypeScript.
packages/eslint-config-bai/bai-plugin.test.js Adds RuleTester coverage.
packages/eslint-config-bai/bai-plugin.js Implements the rule; requires the fixes listed above.
.github/workflows/vitest-root.yml Runs root tests for ESLint-config changes.
Review details
  • Files reviewed: 16/16 changed files
  • Comments generated: 4
  • Review effort level: Balanced

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

Comment thread packages/eslint-config-bai/bai-plugin.js Outdated
Comment on lines +45 to +50
if (
typeNode.type === "TSTypeReference" &&
typeNode.typeName?.type === "Identifier" &&
isKeyTypeName(typeNode.typeName.name)
) {
out.push(typeNode);
Comment on lines +61 to +64
const typeArgs = typeNode.typeArguments || typeNode.typeParameters;
if (typeArgs && Array.isArray(typeArgs.params)) {
typeArgs.params.forEach((c) => collectKeyTypeRefs(c, out));
}
Comment thread packages/eslint-config-bai/bai-plugin.js Outdated
Skip GraphQL `#` comments and string / block-string tokens before the
textual scan, so a spread or a fragment definition named only in prose
neither registers as a spread nor exempts a real violation.

Recognize `React.useState<Foo$key>` alongside the bare call form; the
member-expression spelling is established in packages/backend.ai-ui.

Four RuleTester cases cover both, keeping the suite at 13 passing.

Claude-Session: https://claude.ai/code/session_011RFmSEBxxxvCXyquSPtqVJ
@github-actions github-actions Bot added size:XL 500~ LoC and removed size:L 100~500 LoC labels Sep 7, 2026
@yomybaby
yomybaby requested a balanced review from Copilot September 7, 2026 16:47

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.

🟡 Changes recommended

Two moderate correctness issues can cause false positives and allow aliased generated $key imports to bypass the rule.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

packages/eslint-config-bai/bai-plugin.js:87

  • This does not verify that the referenced name is an imported Relay generated type. It therefore reports unrelated local types named Foo$key, while an actual generated import can bypass the rule via import { Foo$key as FooKey } ... because FooKey no longer has the suffix. The issue's contract explicitly requires an import from __generated__/*.graphql; collect those import specifiers (including their local aliases) and match candidates through that map, with positive and negative RuleTester cases.
  if (
    typeNode.type === "TSTypeReference" &&
    typeNode.typeName?.type === "Identifier" &&
    isKeyTypeName(typeNode.typeName.name)
  ) {
    out.push(typeNode);
  • Files reviewed: 16/16 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread packages/eslint-config-bai/bai-plugin.js
Close a block string at its first UNESCAPED `"""`. A `\"""` escape is the
one sequence a GraphQL block string has, and treating it as the closing
delimiter let the rest of the string be scanned as syntax.

Claude-Session: https://claude.ai/code/session_011RFmSEBxxxvCXyquSPtqVJ
@yomybaby

yomybaby commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Ready-gate status — two Copilot passes run, PR stays a draft pending two decisions.

Fixed and resolved (3):

  • GraphQL # comments and string / block-string tokens are now skipped before the textual scan (3667ecd7a), and a block string closes at its first unescaped """ (d079dbdeb).
  • React.useState<Foo$key> is now recognized alongside the bare call form (3667ecd7a). Zero new violations: the member-expression spelling with a $key type has no occurrences in react/src or packages/backend.ai-ui/src today.

Rule suite 9 -> 14 passing; bash scripts/verify.sh ends with === ALL PASS ===.

Left open for a human call (2) — both threads are unreplied on purpose:

  1. Import-tracking vs. name-based matching (bai-plugin.js:87). Copilot wants candidates matched through the generated-module ImportSpecifier map, so Foo$key as FooRef is caught and an unrelated local Foo$key is not. The PR body picks name-based matching deliberately, and neither failure mode occurs in the tree today (no aliased $key imports exist). Whether to trade the extra machinery for those two edge cases is a design call.
  2. Walking object type members (bai-plugin.js:101). Copilot is right that useState<{ revisionFrgmt: X$key } | null> is not reported, and the instance it cites is real — DeploymentCurrentRevisionTab.tsx:59-63 spreads ...DeploymentRevisionDetail_revision at lines 47 and 52. Closing the gap widens this PR to a 12th violation in a file it does not currently touch, and the fix there is not mechanical (the state shape feeds a callback whose parameter is an exempt position). Fix here, or follow-up issue?

https://claude.ai/code/session_011RFmSEBxxxvCXyquSPtqVJ

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add ESLint rule: forbid importing a fragment's $key type at its spread site

2 participants