Skip to content

Commit 50eb82c

Browse files
committed
chore: add lint rule for Node type
1 parent a85320c commit 50eb82c

File tree

5 files changed

+281
-16
lines changed

5 files changed

+281
-16
lines changed

docs/graphql-linting.md

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -77,30 +77,32 @@ The custom GraphQL linter includes the following built-in rules:
7777

7878
### Core GraphQL Rules
7979

80-
| Rule | Severity | Description |
81-
| ---------------------------- | -------- | ------------------------------------- |
82-
| `no-anonymous-operations` | error | Prevent anonymous GraphQL operations |
83-
| `no-duplicate-fields` | error | Prevent duplicate field definitions |
84-
| `require-description` | warn | Require descriptions for types/fields |
85-
| `require-deprecation-reason` | warn | Require reason for deprecated fields |
86-
| `field-naming-convention` | warn | Enforce camelCase field naming |
87-
| `root-fields-nullable` | warn | Suggest nullable root field types |
80+
| Rule | Severity | Description |
81+
| ---------------------------- | -------- | --------------------------------------------------- |
82+
| `no-anonymous-operations` | error | Prevent anonymous GraphQL operations |
83+
| `no-duplicate-fields` | error | Prevent duplicate field definitions |
84+
| `require-description` | warn | Require descriptions for types/fields |
85+
| `require-deprecation-reason` | warn | Require reason for deprecated fields |
86+
| `node-interface-structure` | error | Node interface must have exactly one field: id: ID! |
87+
| `field-naming-convention` | warn | Enforce camelCase field naming |
88+
| `root-fields-nullable` | warn | Suggest nullable root field types |
8889

8990
### Pagination Rules
9091

91-
| Rule | Severity | Description |
92-
| ------------------------------ | -------- | ---------------------------------------------- |
93-
| `connection-structure` | error | Ensure Connection types have edges/pageInfo |
94-
| `edge-structure` | error | Ensure Edge types have node/cursor fields |
95-
| `connection-arguments` | warn | Suggest pagination arguments for connections |
96-
| `pagination-argument-types` | error | Enforce correct types for pagination arguments |
92+
| Rule | Severity | Description |
93+
| --------------------------- | -------- | ---------------------------------------------- |
94+
| `connection-structure` | error | Ensure Connection types have edges/pageInfo |
95+
| `edge-structure` | error | Ensure Edge types have node/cursor fields |
96+
| `connection-arguments` | warn | Suggest pagination arguments for connections |
97+
| `pagination-argument-types` | error | Enforce correct types for pagination arguments |
9798

9899
### Rule Details
99100

100101
- **no-anonymous-operations**: Ensures all GraphQL operations (queries, mutations, subscriptions) have names
101102
- **no-duplicate-fields**: Prevents duplicate field definitions within the same type
102103
- **require-description**: Suggests adding descriptions to types and fields for better documentation
103104
- **require-deprecation-reason**: Ensures deprecated fields include a reason for deprecation
105+
- **node-interface-structure**: Ensures Node interface follows the standard pattern with exactly one field: `id: ID!`
104106
- **field-naming-convention**: Enforces camelCase naming for field names (ignores special fields like `__typename`)
105107
- **root-fields-nullable**: Suggests making root type fields nullable for better error handling
106108
- **connection-structure**: Ensures Connection types follow the Relay pagination pattern with `edges` and `pageInfo` fields

src/services/graphqlLinter.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ObjectTypeDefinitionNode,
1313
FieldDefinitionNode,
1414
OperationDefinitionNode,
15+
InterfaceTypeDefinitionNode,
1516
} from "graphql";
1617
import { logger } from "./logger";
1718
import { StepZenError } from "../errors";
@@ -60,7 +61,7 @@ export class GraphQLLinterService {
6061
private initializeRules(): void {
6162
// Get enabled rules from configuration
6263
const config = vscode.workspace.getConfiguration("stepzen");
63-
const enabledRules = config.get("graphqlLintRules", {
64+
const defaultRules = {
6465
"no-anonymous-operations": true,
6566
"no-duplicate-fields": true,
6667
"require-description": true,
@@ -71,7 +72,15 @@ export class GraphQLLinterService {
7172
"edge-structure": true,
7273
"connection-arguments": true,
7374
"pagination-argument-types": true,
74-
});
75+
"node-interface-structure": true,
76+
};
77+
78+
// In test environment, config might be undefined, so use defaults
79+
let enabledRules = defaultRules;
80+
if (config) {
81+
const configRules = config.get("graphqlLintRules", {});
82+
enabledRules = { ...defaultRules, ...configRules };
83+
}
7584

7685
const allRules: GraphQLLintRule[] = [];
7786

@@ -535,6 +544,75 @@ export class GraphQLLinterService {
535544
});
536545
}
537546

547+
// Rule: Node interface must have exactly one field: id: ID!
548+
if (enabledRules["node-interface-structure"]) {
549+
allRules.push({
550+
name: "node-interface-structure",
551+
severity: "error" as const,
552+
check: (ast: DocumentNode): GraphQLLintIssue[] => {
553+
const issues: GraphQLLintIssue[] = [];
554+
555+
visit(ast, {
556+
InterfaceTypeDefinition(node: InterfaceTypeDefinitionNode) {
557+
if (node.name.value === "Node") {
558+
const fields = node.fields || [];
559+
560+
// Not exactly one field: error
561+
if (fields.length !== 1 && node.loc) {
562+
issues.push({
563+
message: "Node interface must have exactly one field: id: ID!",
564+
line: node.loc.startToken.line,
565+
column: node.loc.startToken.column,
566+
endLine: node.loc.endToken.line,
567+
endColumn: node.loc.endToken.column,
568+
rule: "node-interface-structure",
569+
severity: "error",
570+
});
571+
}
572+
573+
// Check the field name and type
574+
if (fields.length >= 1) {
575+
const idField = fields[0];
576+
577+
if (idField.name.value !== "id" && idField.loc) {
578+
issues.push({
579+
message: "Node interface must have a field named 'id'",
580+
line: idField.loc.startToken.line,
581+
column: idField.loc.startToken.column,
582+
endLine: idField.loc.endToken.line,
583+
endColumn: idField.loc.endToken.column,
584+
rule: "node-interface-structure",
585+
severity: "error",
586+
});
587+
}
588+
589+
// Check if the field type is ID!
590+
const fieldType = idField.type;
591+
592+
const isNonNullID =
593+
fieldType.kind === "NonNullType" &&
594+
fieldType.type.kind === "NamedType" &&
595+
fieldType.type.name.value === "ID";
596+
if (!isNonNullID && idField.loc) {
597+
issues.push({
598+
message: "Node interface 'id' field must be of type 'ID!'",
599+
line: idField.loc.startToken.line,
600+
column: idField.loc.startToken.column,
601+
endLine: idField.loc.endToken.line,
602+
endColumn: idField.loc.endToken.column,
603+
rule: "node-interface-structure",
604+
severity: "error",
605+
});
606+
}
607+
}
608+
}
609+
},
610+
});
611+
return issues;
612+
},
613+
});
614+
}
615+
538616
this.rules = allRules;
539617
}
540618

src/test/unit/graphqlLinter.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,7 @@ suite("GraphQL Linter Test Suite", () => {
560560
"edge-structure": false,
561561
"connection-arguments": false,
562562
"pagination-argument-types": false,
563+
"node-interface-structure": false,
563564
};
564565
}
565566
return defaultValue;
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* Copyright IBM Corp. 2025
3+
* Assisted by CursorAI
4+
*/
5+
6+
import * as assert from 'assert';
7+
import { GraphQLLinterService } from '../../../services/graphqlLinter';
8+
9+
suite("GraphQL Linter Test Suite", () => {
10+
let linter: GraphQLLinterService;
11+
12+
suiteSetup(() => {
13+
linter = new GraphQLLinterService();
14+
});
15+
16+
suiteTeardown(() => {
17+
linter.dispose();
18+
});
19+
20+
test("should initialize GraphQL linter service", async () => {
21+
await linter.initialize();
22+
assert.ok(linter.getDiagnosticCollection(), "Diagnostic collection should be created");
23+
});
24+
25+
test("should create diagnostic collection with correct name", () => {
26+
const collection = linter.getDiagnosticCollection();
27+
assert.strictEqual(collection.name, 'stepzen-graphql-lint', "Diagnostic collection should have correct name");
28+
});
29+
30+
test("should clear diagnostics", () => {
31+
const collection = linter.getDiagnosticCollection();
32+
linter.clearDiagnostics();
33+
let filesWithIssues = 0;
34+
collection.forEach(() => {
35+
filesWithIssues++;
36+
});
37+
assert.strictEqual(filesWithIssues, 0, "Should clear all diagnostics");
38+
});
39+
40+
test("should dispose service correctly", () => {
41+
linter.dispose();
42+
// Test that dispose doesn't throw errors
43+
assert.ok(true, "Dispose should complete without errors");
44+
});
45+
46+
47+
48+
test("should detect Node interface with wrong field name", async () => {
49+
await linter.initialize();
50+
const testFile = 'test-node-wrong-field.graphql';
51+
const content = 'interface Node { identifier: ID! }';
52+
53+
const fs = require('fs');
54+
fs.writeFileSync(testFile, content);
55+
56+
try {
57+
const diagnostics = await linter.lintFile(testFile);
58+
59+
const nodeInterfaceErrors = diagnostics.filter(d =>
60+
d.message.includes("Node interface must have a field named 'id'") &&
61+
d.code === "node-interface-structure"
62+
);
63+
64+
assert.strictEqual(nodeInterfaceErrors.length, 1, "Should detect Node interface with wrong field name");
65+
} finally {
66+
fs.unlinkSync(testFile);
67+
}
68+
});
69+
70+
test("should detect Node interface with wrong field type", async () => {
71+
await linter.initialize();
72+
const testFile = 'test-node-wrong-type.graphql';
73+
const content = 'interface Node { id: String! }';
74+
75+
const fs = require('fs');
76+
fs.writeFileSync(testFile, content);
77+
78+
try {
79+
const diagnostics = await linter.lintFile(testFile);
80+
81+
const nodeInterfaceErrors = diagnostics.filter(d =>
82+
d.message.includes("Node interface 'id' field must be of type 'ID!'") &&
83+
d.code === "node-interface-structure"
84+
);
85+
86+
assert.strictEqual(nodeInterfaceErrors.length, 1, "Should detect Node interface with wrong field type");
87+
} finally {
88+
fs.unlinkSync(testFile);
89+
}
90+
});
91+
92+
test("should accept correct Node interface", async () => {
93+
await linter.initialize();
94+
const testFile = 'test-node-correct.graphql';
95+
const content = 'interface Node { id: ID! }';
96+
97+
const fs = require('fs');
98+
fs.writeFileSync(testFile, content);
99+
100+
try {
101+
const diagnostics = await linter.lintFile(testFile);
102+
const nodeInterfaceErrors = diagnostics.filter(d => d.code === "node-interface-structure");
103+
104+
assert.strictEqual(nodeInterfaceErrors.length, 0, "Should accept correct Node interface");
105+
} finally {
106+
fs.unlinkSync(testFile);
107+
}
108+
});
109+
110+
test("should not affect other interfaces", async () => {
111+
await linter.initialize();
112+
const testFile = 'test-other-interfaces.graphql';
113+
const content = `
114+
interface User {
115+
id: ID!
116+
name: String!
117+
email: String!
118+
}
119+
120+
interface Product {
121+
id: ID!
122+
name: String!
123+
price: Float!
124+
}
125+
`;
126+
127+
const fs = require('fs');
128+
fs.writeFileSync(testFile, content);
129+
130+
try {
131+
const diagnostics = await linter.lintFile(testFile);
132+
const nodeInterfaceErrors = diagnostics.filter(d => d.code === "node-interface-structure");
133+
134+
assert.strictEqual(nodeInterfaceErrors.length, 0, "Should not affect other interfaces");
135+
} finally {
136+
fs.unlinkSync(testFile);
137+
}
138+
});
139+
});

test-node-interface.graphql

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Test file for Node interface rule
2+
3+
# ❌ Wrong: Node interface with no fields
4+
interface Node {
5+
}
6+
7+
# ❌ Wrong: Node interface with wrong field name
8+
interface Node {
9+
identifier: ID!
10+
}
11+
12+
# ❌ Wrong: Node interface with wrong field type
13+
interface Node {
14+
id: String!
15+
}
16+
17+
# ❌ Wrong: Node interface with nullable ID
18+
interface Node {
19+
id: ID
20+
}
21+
22+
# ❌ Wrong: Node interface with multiple fields
23+
interface Node {
24+
id: ID!
25+
name: String!
26+
}
27+
28+
# ✅ Correct: Node interface with exactly id: ID!
29+
interface Node {
30+
id: ID!
31+
}
32+
33+
# Test that other interfaces are not affected
34+
interface User {
35+
id: ID!
36+
name: String!
37+
email: String!
38+
}
39+
40+
# Test that types are not affected
41+
type Product {
42+
id: ID!
43+
name: String!
44+
price: Float!
45+
}

0 commit comments

Comments
 (0)