From 75fc02a56e88fbb6bec9f1e03a8d1d41db3eca66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9andre=20Daumont?= <1338620+digiz3d@users.noreply.github.com> Date: Fri, 11 Oct 2024 01:22:27 +0200 Subject: [PATCH] feat: add deprecated arg detection and tests (#5) --- README.md | 8 +++- dist/index.js | 22 ++++++++- package-lock.json | 4 +- package.json | 2 +- src/index.ts | 4 +- src/utils.ts | 26 +++++++++-- test/{index.ts => e2e.ts} | 22 ++++++++- test/unit.ts | 97 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 172 insertions(+), 13 deletions(-) rename test/{index.ts => e2e.ts} (57%) create mode 100644 test/unit.ts diff --git a/README.md b/README.md index 66e468c..75777c4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ Any deprecated field will be reported and make the CI fail. ## Usage -You need the operation files (queries/mutations/subscriptions) and the schema file. +You need the operation files (queries/mutations/subscriptions) and the schema file. +[According to the GraphQL spec](https://spec.graphql.org/draft/#sec-Root-Operation-Types), the schema must define at least a field of the Query root type. ### Input parameters @@ -49,6 +50,9 @@ If you don't commit the schema, then you might fetch it using rover: ## Tech details -Using [ncc](https://github.com/vercel/ncc) as a bundler +Using [ncc](https://github.com/vercel/ncc) as a bundler. `dist` is committed be able to run with a `node20` runner without any installation step. + +This action does yet not scan object literals. It is fine as they are often replaced by variables in client operation files. +Feel free to submit a PR (with tests) if you would like to support it. \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 6e096d7..ce7d1a1 100644 --- a/dist/index.js +++ b/dist/index.js @@ -49509,7 +49509,9 @@ const schema = (0,graphql__WEBPACK_IMPORTED_MODULE_2__/* .buildSchema */ .IV5)(s const deprecatedFields = (0,_utils__WEBPACK_IMPORTED_MODULE_1__/* .validateOperationsAndReportDeprecatedFields */ .So)(schema, operationDocuments, fragmentsByName, dependenciesByFragmentName, shouldReportFiles); if (deprecatedFields.size > 0) { console.error('Deprecated fields found.'); - deprecatedFields.forEach((field) => console.error(field)); + Array.from(deprecatedFields) + .toSorted((a, b) => a.localeCompare(b)) + .forEach((field) => console.error(field)); process.exit(1); } else { @@ -57302,12 +57304,28 @@ function validateOperationsAndReportDeprecatedFields(schema, documentsMap, fragm } const typeInfo = new graphql/* TypeInfo */.DxK(schema); (0,graphql/* visit */.YRT)(documentWithFragments, (0,graphql/* visitWithTypeInfo */.Sw7)(typeInfo, { + Argument(node) { + const argDef = typeInfo.getArgument(); + if (argDef && argDef.deprecationReason) { + const parentType = typeInfo.getFieldDef(); + const parentTypeName = parentType ? parentType.name : 'Unknown Type'; + deprecatedFields.add(`Argument "${node.name.value}" from "${parentTypeName}" is deprecated${shouldReportFiles ? ` in ${operationFilePath}` : ''}`); + } + }, Field(node) { const fieldDef = typeInfo.getFieldDef(); if (fieldDef && fieldDef.deprecationReason) { const parentType = typeInfo.getParentType(); const parentTypeName = parentType ? parentType.name : 'Unknown Type'; - deprecatedFields.add(`"${parentTypeName}.${node.name.value}" is deprecated${shouldReportFiles ? ` in ${operationFilePath}` : ''}`); + switch (parentTypeName) { + case 'Mutation': + case 'Query': + case 'Subscription': + deprecatedFields.add(`${parentTypeName} "${node.name.value}" is deprecated${shouldReportFiles ? ` in ${operationFilePath}` : ''}`); + break; + default: + deprecatedFields.add(`Field "${parentTypeName}.${node.name.value}" is deprecated${shouldReportFiles ? ` in ${operationFilePath}` : ''}`); + } } }, })); diff --git a/package-lock.json b/package-lock.json index 8d30aae..fed125a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lint-graphql-operations", - "version": "0.1.5", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lint-graphql-operations", - "version": "0.1.5", + "version": "1.0.0", "dependencies": { "@actions/core": "^1.11.1", "glob": "^11.0.0", diff --git a/package.json b/package.json index 8a4e03d..d889edc 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "0.1.5", + "version": "1.0.0", "name": "lint-graphql-operations", "module": "dist/index.js", "type": "module", diff --git a/src/index.ts b/src/index.ts index fbaaa11..475b2f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,7 +38,9 @@ const deprecatedFields = validateOperationsAndReportDeprecatedFields( if (deprecatedFields.size > 0) { console.error('Deprecated fields found.') - deprecatedFields.forEach((field) => console.error(field)) + Array.from(deprecatedFields) + .toSorted((a, b) => a.localeCompare(b)) + .forEach((field) => console.error(field)) process.exit(1) } else { console.log('No deprecated fields found. GG!') diff --git a/src/utils.ts b/src/utils.ts index 85feb21..da017e6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -114,14 +114,34 @@ export function validateOperationsAndReportDeprecatedFields( visit( documentWithFragments, visitWithTypeInfo(typeInfo, { + Argument(node) { + const argDef = typeInfo.getArgument() + if (argDef && argDef.deprecationReason) { + const parentType = typeInfo.getFieldDef() + const parentTypeName = parentType ? parentType.name : 'Unknown Type' + deprecatedFields.add( + `Argument "${node.name.value}" from "${parentTypeName}" is deprecated${shouldReportFiles ? ` in ${operationFilePath}` : ''}`, + ) + } + }, Field(node) { const fieldDef = typeInfo.getFieldDef() if (fieldDef && fieldDef.deprecationReason) { const parentType = typeInfo.getParentType() const parentTypeName = parentType ? parentType.name : 'Unknown Type' - deprecatedFields.add( - `"${parentTypeName}.${node.name.value}" is deprecated${shouldReportFiles ? ` in ${operationFilePath}` : ''}`, - ) + switch (parentTypeName) { + case 'Mutation': + case 'Query': + case 'Subscription': + deprecatedFields.add( + `${parentTypeName} "${node.name.value}" is deprecated${shouldReportFiles ? ` in ${operationFilePath}` : ''}`, + ) + break + default: + deprecatedFields.add( + `Field "${parentTypeName}.${node.name.value}" is deprecated${shouldReportFiles ? ` in ${operationFilePath}` : ''}`, + ) + } } }, }), diff --git a/test/index.ts b/test/e2e.ts similarity index 57% rename from test/index.ts rename to test/e2e.ts index da0dd87..b35f8e8 100644 --- a/test/index.ts +++ b/test/e2e.ts @@ -30,7 +30,16 @@ test('should find all deprecated usages', async () => { dependenciesByFragmentName, false, ) - assert.equal(reportTypes.size, 3) + assert.equal(reportTypes.size, 4) + assert.deepEqual( + Array.from(reportTypes).toSorted((a, b) => a.localeCompare(b)), + [ + 'Argument "input" from "someQueryWithDeprecatedInput" is deprecated', + 'Field "SomeSubType.subTypeDeepDeprecatedField" is deprecated', + 'Field "SomeType.someDeprecatedField" is deprecated', + 'Query "someDeprecatedQuery" is deprecated', + ], + ) const reportTypesWithFiles = validateOperationsAndReportDeprecatedFields( schema, @@ -39,5 +48,14 @@ test('should find all deprecated usages', async () => { dependenciesByFragmentName, true, ) - assert.equal(reportTypesWithFiles.size, 3) + assert.equal(reportTypesWithFiles.size, 4) + assert.deepEqual( + Array.from(reportTypesWithFiles).toSorted((a, b) => a.localeCompare(b)), + [ + 'Argument "input" from "someQueryWithDeprecatedInput" is deprecated in test/query.graphql', + 'Field "SomeSubType.subTypeDeepDeprecatedField" is deprecated in test/query.graphql', + 'Field "SomeType.someDeprecatedField" is deprecated in test/query.graphql', + 'Query "someDeprecatedQuery" is deprecated in test/query.graphql', + ], + ) }) diff --git a/test/unit.ts b/test/unit.ts new file mode 100644 index 0000000..67910e7 --- /dev/null +++ b/test/unit.ts @@ -0,0 +1,97 @@ +import { buildSchema, parse, type DocumentNode } from 'graphql' +import test from 'node:test' +import { validateOperationsAndReportDeprecatedFields } from '../src/utils' +import assert from 'node:assert' + +function makeDocumentMap(str: string) { + const documentsMap = new Map() + documentsMap.set('test.graphql', parse(str)) + return documentsMap +} + +function buildSchemaWithRootQuery(str: string) { + // GraphQL validation rules imply that a schema must have a Query type + if (str.match(/type\sQuery[^a-zA-Z0-9_]/)) return buildSchema(str) + return buildSchema('type Query { ok: String } ' + str) +} + +function assertValid(schemaString: string, queryString: string, expectedResult: string) { + const schema = buildSchemaWithRootQuery(schemaString) + const documentsMap = makeDocumentMap(queryString) + const deprecatedFields = validateOperationsAndReportDeprecatedFields( + schema, + documentsMap, + new Map(), + new Map(), + false, + ) + assert.equal(deprecatedFields.size, 1) + assert.equal(deprecatedFields.values().next().value, expectedResult) +} + +test('find deprecated query', () => { + assertValid( + ` + type Query { + someQuery: String @deprecated(reason: "Use nothing instead") + } + `, + `query Test { someQuery }`, + 'Query "someQuery" is deprecated', + ) +}) + +test('find deprecated mutation', () => { + assertValid( + ` + type Mutation { + someMutation(input: String!): String! @deprecated(reason: "Use nothing instead") + } + `, + `mutation Test { someMutation(input:"hi") }`, + 'Mutation "someMutation" is deprecated', + ) +}) + +test('find deprecated subscription', () => { + assertValid( + ` + type Subscription { + someSubscription(input: String!): String! @deprecated(reason: "Use nothing instead") + } + `, + `subscription Test { someSubscription(input:"hi") }`, + 'Subscription "someSubscription" is deprecated', + ) +}) + +test('find deprecated field', () => { + assertValid( + ` + type SomePayload { + ok: Boolean + notOk: String @deprecated(reason: "Use ok instead") + } + type Query { + someQuery: SomePayload + } + `, + `query Test { someQuery { ok notOk } }`, + 'Field "SomePayload.notOk" is deprecated', + ) +}) + +test('find deprecated arg', () => { + assertValid( + ` + type SomePayload { + ok: Boolean + } + type Query { + someQuery(arg: String @deprecated(reason: "Stop using it")): SomePayload + } + `, + `query Test { someQuery(arg: "hi") { ok } }`, + 'Argument "arg" from "someQuery" is deprecated', + ) +})