Skip to content

Commit f979c3e

Browse files
authored
chore: add lint rule for Node type (#116)
* chore: add lint rule for Node type * chore: ensure Node is not an object type
1 parent 19c708d commit f979c3e

File tree

5 files changed

+354
-16
lines changed

5 files changed

+354
-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: 97 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,92 @@ 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+
// Check for Node defined as object type (should be interface)
557+
ObjectTypeDefinition(node: ObjectTypeDefinitionNode) {
558+
if (node.name.value === "Node" && node.loc) {
559+
issues.push({
560+
message: "Node must be defined as an interface, not a type",
561+
line: node.loc.startToken.line,
562+
column: node.loc.startToken.column,
563+
endLine: node.loc.endToken.line,
564+
endColumn: node.loc.endToken.column,
565+
rule: "node-interface-structure",
566+
severity: "error",
567+
});
568+
}
569+
},
570+
// Check for Node interface structure
571+
InterfaceTypeDefinition(node: InterfaceTypeDefinitionNode) {
572+
if (node.name.value === "Node") {
573+
const fields = node.fields || [];
574+
575+
// Not exactly one field: error
576+
if (fields.length !== 1 && node.loc) {
577+
issues.push({
578+
message:
579+
"Node interface must have exactly one field: id: ID!",
580+
line: node.loc.startToken.line,
581+
column: node.loc.startToken.column,
582+
endLine: node.loc.endToken.line,
583+
endColumn: node.loc.endToken.column,
584+
rule: "node-interface-structure",
585+
severity: "error",
586+
});
587+
}
588+
589+
// Check the field name and type
590+
if (fields.length >= 1) {
591+
const idField = fields[0];
592+
593+
if (idField.name.value !== "id" && idField.loc) {
594+
issues.push({
595+
message: "Node interface must have a field named 'id'",
596+
line: idField.loc.startToken.line,
597+
column: idField.loc.startToken.column,
598+
endLine: idField.loc.endToken.line,
599+
endColumn: idField.loc.endToken.column,
600+
rule: "node-interface-structure",
601+
severity: "error",
602+
});
603+
}
604+
605+
// Check if the field type is ID!
606+
const fieldType = idField.type;
607+
608+
const isNonNullID =
609+
fieldType.kind === "NonNullType" &&
610+
fieldType.type.kind === "NamedType" &&
611+
fieldType.type.name.value === "ID";
612+
if (!isNonNullID && idField.loc) {
613+
issues.push({
614+
message:
615+
"Node interface 'id' field must be of type 'ID!'",
616+
line: idField.loc.startToken.line,
617+
column: idField.loc.startToken.column,
618+
endLine: idField.loc.endToken.line,
619+
endColumn: idField.loc.endToken.column,
620+
rule: "node-interface-structure",
621+
severity: "error",
622+
});
623+
}
624+
}
625+
}
626+
},
627+
});
628+
return issues;
629+
},
630+
});
631+
}
632+
538633
this.rules = allRules;
539634
}
540635

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: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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(
23+
linter.getDiagnosticCollection(),
24+
"Diagnostic collection should be created",
25+
);
26+
});
27+
28+
test("should create diagnostic collection with correct name", () => {
29+
const collection = linter.getDiagnosticCollection();
30+
assert.strictEqual(
31+
collection.name,
32+
"stepzen-graphql-lint",
33+
"Diagnostic collection should have correct name",
34+
);
35+
});
36+
37+
test("should clear diagnostics", () => {
38+
const collection = linter.getDiagnosticCollection();
39+
linter.clearDiagnostics();
40+
let filesWithIssues = 0;
41+
collection.forEach(() => {
42+
filesWithIssues++;
43+
});
44+
assert.strictEqual(filesWithIssues, 0, "Should clear all diagnostics");
45+
});
46+
47+
test("should dispose service correctly", () => {
48+
linter.dispose();
49+
// Test that dispose doesn't throw errors
50+
assert.ok(true, "Dispose should complete without errors");
51+
});
52+
53+
test("should detect Node interface with wrong field name", async () => {
54+
await linter.initialize();
55+
const testFile = "test-node-wrong-field.graphql";
56+
const content = "interface Node { identifier: ID! }";
57+
58+
const fs = require("fs");
59+
fs.writeFileSync(testFile, content);
60+
61+
try {
62+
const diagnostics = await linter.lintFile(testFile);
63+
64+
const nodeInterfaceErrors = diagnostics.filter(
65+
(d) =>
66+
d.message.includes("Node interface must have a field named 'id'") &&
67+
d.code === "node-interface-structure",
68+
);
69+
70+
assert.strictEqual(
71+
nodeInterfaceErrors.length,
72+
1,
73+
"Should detect Node interface with wrong field name",
74+
);
75+
} finally {
76+
fs.unlinkSync(testFile);
77+
}
78+
});
79+
80+
test("should detect Node interface with wrong field type", async () => {
81+
await linter.initialize();
82+
const testFile = "test-node-wrong-type.graphql";
83+
const content = "interface Node { id: String! }";
84+
85+
const fs = require("fs");
86+
fs.writeFileSync(testFile, content);
87+
88+
try {
89+
const diagnostics = await linter.lintFile(testFile);
90+
91+
const nodeInterfaceErrors = diagnostics.filter(
92+
(d) =>
93+
d.message.includes(
94+
"Node interface 'id' field must be of type 'ID!'",
95+
) && d.code === "node-interface-structure",
96+
);
97+
98+
assert.strictEqual(
99+
nodeInterfaceErrors.length,
100+
1,
101+
"Should detect Node interface with wrong field type",
102+
);
103+
} finally {
104+
fs.unlinkSync(testFile);
105+
}
106+
});
107+
108+
test("should accept correct Node interface", async () => {
109+
await linter.initialize();
110+
const testFile = "test-node-correct.graphql";
111+
const content = "interface Node { id: ID! }";
112+
113+
const fs = require("fs");
114+
fs.writeFileSync(testFile, content);
115+
116+
try {
117+
const diagnostics = await linter.lintFile(testFile);
118+
const nodeInterfaceErrors = diagnostics.filter(
119+
(d) => d.code === "node-interface-structure",
120+
);
121+
122+
assert.strictEqual(
123+
nodeInterfaceErrors.length,
124+
0,
125+
"Should accept correct Node interface",
126+
);
127+
} finally {
128+
fs.unlinkSync(testFile);
129+
}
130+
});
131+
132+
test("should not affect other interfaces", async () => {
133+
await linter.initialize();
134+
const testFile = "test-other-interfaces.graphql";
135+
const content = `
136+
interface User {
137+
id: ID!
138+
name: String!
139+
email: String!
140+
}
141+
142+
interface Product {
143+
id: ID!
144+
name: String!
145+
price: Float!
146+
}
147+
`;
148+
149+
const fs = require("fs");
150+
fs.writeFileSync(testFile, content);
151+
152+
try {
153+
const diagnostics = await linter.lintFile(testFile);
154+
const nodeInterfaceErrors = diagnostics.filter(
155+
(d) => d.code === "node-interface-structure",
156+
);
157+
158+
assert.strictEqual(
159+
nodeInterfaceErrors.length,
160+
0,
161+
"Should not affect other interfaces",
162+
);
163+
} finally {
164+
fs.unlinkSync(testFile);
165+
}
166+
});
167+
168+
test("should detect Node defined as object type instead of interface", async () => {
169+
await linter.initialize();
170+
const testFile = "test-node-as-type.graphql";
171+
const content = "type Node { id: ID! }";
172+
173+
const fs = require("fs");
174+
fs.writeFileSync(testFile, content);
175+
176+
try {
177+
const diagnostics = await linter.lintFile(testFile);
178+
179+
const nodeInterfaceErrors = diagnostics.filter(
180+
(d) =>
181+
d.message.includes(
182+
"Node must be defined as an interface, not a type",
183+
) && d.code === "node-interface-structure",
184+
);
185+
186+
assert.strictEqual(
187+
nodeInterfaceErrors.length,
188+
1,
189+
"Should detect Node defined as object type instead of interface",
190+
);
191+
} finally {
192+
fs.unlinkSync(testFile);
193+
}
194+
});
195+
});

0 commit comments

Comments
 (0)