Mirror: TypeScript LSP plugin that finds GraphQL documents in your code and provides diagnostics, auto-complete and hover-information.

fix(graphqlsp): Fix unchecked index accesses (#335)

authored by kitten.sh and committed by

GitHub 58f90f0f 14106a1a

+128 -116
+5
.changeset/dry-moose-camp.md
··· 1 + --- 2 + '@0no-co/graphqlsp': patch 3 + --- 4 + 5 + Address potential crashes on malformed TypeScript AST input (such as missing function arguments where they were previously assumed to be passed)
+7 -2
packages/graphqlsp/src/ast/checks.ts
··· 43 43 return false; 44 44 } else if (node.arguments.length < 1 || node.arguments.length > 2) { 45 45 return false; 46 - } else if (!ts.isStringLiteralLike(node.arguments[0])) { 46 + } else if (!ts.isStringLiteralLike(node.arguments[0]!)) { 47 47 return false; 48 48 } 49 49 return checker ? isTadaGraphQLFunction(node.expression, checker) : false; ··· 70 70 } 71 71 }; 72 72 73 + // As per check in `isGraphQLCall()` below, enforces arguments length 74 + export type GraphQLCallNode = ts.CallExpression & { 75 + arguments: [ts.Expression] | [ts.Expression, ts.Expression]; 76 + }; 77 + 73 78 /** Checks if node is a gql.tada or regular graphql() call */ 74 79 export const isGraphQLCall = ( 75 80 node: ts.Node, 76 81 checker: ts.TypeChecker | undefined 77 - ): node is ts.CallExpression => { 82 + ): node is GraphQLCallNode => { 78 83 return ( 79 84 ts.isCallExpression(node) && 80 85 node.arguments.length >= 1 &&
+3 -3
packages/graphqlsp/src/ast/index.ts
··· 60 60 element.getStart() 61 61 ); 62 62 63 - if (!definitions || !definitions.length) return fragments; 64 - 65 - const [fragment] = definitions; 63 + const fragment = definitions && definitions[0]; 64 + if (!fragment) return fragments; 66 65 67 66 const externalSource = getSource(info, fragment.fileName); 68 67 if (!externalSource) return fragments; ··· 263 262 if ( 264 263 ts.isVariableStatement(node) && 265 264 node.declarationList && 265 + node.declarationList.declarations[0] && 266 266 node.declarationList.declarations[0].name.getText() === 'documents' 267 267 ) { 268 268 const [declaration] = node.declarationList.declarations;
+3 -2
packages/graphqlsp/src/ast/resolve.ts
··· 38 38 filename, 39 39 span.expression.getStart() 40 40 ); 41 - if (!definitions || !definitions.length) return; 41 + 42 + const def = definitions && definitions[0]; 43 + if (!def) return; 42 44 43 - const def = definitions[0]; 44 45 const src = getSource(info, def.fileName); 45 46 if (!src) return; 46 47
+1 -1
packages/graphqlsp/src/ast/token.ts
··· 63 63 } 64 64 } 65 65 66 - cPos += input[line].length + 1; 66 + cPos += input[line]!.length + 1; 67 67 } 68 68 69 69 return foundToken;
+1 -1
packages/graphqlsp/src/autoComplete.ts
··· 299 299 let stream: CharacterStream = new CharacterStream(''); 300 300 301 301 for (let i = 0; i < lines.length; i++) { 302 - stream = new CharacterStream(lines[i]); 302 + stream = new CharacterStream(lines[i]!); 303 303 while (!stream.eol()) { 304 304 style = parser.token(stream, state); 305 305 const code = callback(stream, state, style, i);
+24 -42
packages/graphqlsp/src/checkImports.ts
··· 37 37 source.fileName, 38 38 imp.importClause.name.getStart() 39 39 ); 40 - if (definitions && definitions.length) { 41 - const [def] = definitions; 40 + const def = definitions && definitions[0]; 41 + if (def) { 42 42 if (def.fileName.includes('node_modules')) return; 43 43 44 44 const externalSource = getSource(info, def.fileName); ··· 51 51 ); 52 52 53 53 const names = fragmentsForImport.map(fragment => fragment.name.value); 54 - if ( 55 - names.length && 56 - !importSpecifierToFragments[imp.moduleSpecifier.getText()] 57 - ) { 58 - importSpecifierToFragments[imp.moduleSpecifier.getText()] = { 54 + const key = imp.moduleSpecifier.getText(); 55 + let fragmentsEntry = importSpecifierToFragments[key]; 56 + if (names.length && fragmentsEntry) { 57 + fragmentsEntry.fragments = fragmentsEntry.fragments.concat(names); 58 + } else if (names.length && !fragmentsEntry) { 59 + importSpecifierToFragments[key] = fragmentsEntry = { 59 60 start: imp.moduleSpecifier.getStart(), 60 61 length: imp.moduleSpecifier.getText().length, 61 62 fragments: names, 62 63 }; 63 - } else if (names.length) { 64 - importSpecifierToFragments[ 65 - imp.moduleSpecifier.getText() 66 - ].fragments = 67 - importSpecifierToFragments[ 68 - imp.moduleSpecifier.getText() 69 - ].fragments.concat(names); 70 64 } 71 65 } 72 66 } ··· 79 73 source.fileName, 80 74 imp.importClause.namedBindings.getStart() 81 75 ); 82 - if (definitions && definitions.length) { 83 - const [def] = definitions; 76 + const def = definitions && definitions[0]; 77 + if (def) { 84 78 if (def.fileName.includes('node_modules')) return; 85 79 86 80 const externalSource = getSource(info, def.fileName); ··· 92 86 info 93 87 ); 94 88 const names = fragmentsForImport.map(fragment => fragment.name.value); 95 - if ( 96 - names.length && 97 - !importSpecifierToFragments[imp.moduleSpecifier.getText()] 98 - ) { 99 - importSpecifierToFragments[imp.moduleSpecifier.getText()] = { 89 + const key = imp.moduleSpecifier.getText(); 90 + let fragmentsEntry = importSpecifierToFragments[key]; 91 + if (names.length && fragmentsEntry) { 92 + fragmentsEntry.fragments = fragmentsEntry.fragments.concat(names); 93 + } else if (names.length && !fragmentsEntry) { 94 + importSpecifierToFragments[key] = fragmentsEntry = { 100 95 start: imp.moduleSpecifier.getStart(), 101 96 length: imp.moduleSpecifier.getText().length, 102 97 fragments: names, 103 98 }; 104 - } else if (names.length) { 105 - importSpecifierToFragments[ 106 - imp.moduleSpecifier.getText() 107 - ].fragments = 108 - importSpecifierToFragments[ 109 - imp.moduleSpecifier.getText() 110 - ].fragments.concat(names); 111 99 } 112 100 } 113 101 } else if ( ··· 119 107 source.fileName, 120 108 el.getStart() 121 109 ); 122 - if (definitions && definitions.length) { 123 - const [def] = definitions; 110 + const def = definitions && definitions[0]; 111 + if (def) { 124 112 if (def.fileName.includes('node_modules')) return; 125 113 126 114 const externalSource = getSource(info, def.fileName); ··· 134 122 const names = fragmentsForImport.map( 135 123 fragment => fragment.name.value 136 124 ); 137 - if ( 138 - names.length && 139 - !importSpecifierToFragments[imp.moduleSpecifier.getText()] 140 - ) { 141 - importSpecifierToFragments[imp.moduleSpecifier.getText()] = { 125 + const key = imp.moduleSpecifier.getText(); 126 + let fragmentsEntry = importSpecifierToFragments[key]; 127 + if (names.length && fragmentsEntry) { 128 + fragmentsEntry.fragments = fragmentsEntry.fragments.concat(names); 129 + } else if (names.length && !fragmentsEntry) { 130 + importSpecifierToFragments[key] = fragmentsEntry = { 142 131 start: imp.moduleSpecifier.getStart(), 143 132 length: imp.moduleSpecifier.getText().length, 144 133 fragments: names, 145 134 }; 146 - } else if (names.length) { 147 - importSpecifierToFragments[ 148 - imp.moduleSpecifier.getText() 149 - ].fragments = 150 - importSpecifierToFragments[ 151 - imp.moduleSpecifier.getText() 152 - ].fragments.concat(names); 153 135 } 154 136 } 155 137 });
+36 -29
packages/graphqlsp/src/diagnostics.ts
··· 125 125 : texts.join('-') + schema.version 126 126 ); 127 127 128 - let tsDiagnostics; 128 + let tsDiagnostics: ts.Diagnostic[]; 129 129 if (cache.has(cacheKey)) { 130 130 tsDiagnostics = cache.get(cacheKey)!; 131 131 } else { ··· 161 161 ref, 162 162 start, 163 163 length; 164 - if (callExpression.typeArguments) { 165 - const [typeQuery] = callExpression.typeArguments; 164 + const typeQuery = 165 + callExpression.typeArguments && callExpression.typeArguments[0]; 166 + if (typeQuery) { 166 167 start = typeQuery.getStart(); 167 168 length = typeQuery.getEnd() - typeQuery.getStart(); 168 169 ··· 228 229 if ( 229 230 !initializer || 230 231 !ts.isCallExpression(initializer) || 232 + !initializer.arguments[0] || 231 233 !ts.isStringLiteralLike(initializer.arguments[0]) 232 234 ) { 233 235 // TODO: we can make this check more stringent where we also parse and resolve ··· 261 263 info, 262 264 initializer.arguments[0], 263 265 foundFilename, 264 - ts.isArrayLiteralExpression(initializer.arguments[1]) 266 + initializer.arguments[1] && 267 + ts.isArrayLiteralExpression(initializer.arguments[1]) 265 268 ? initializer.arguments[1] 266 269 : undefined 267 270 ); ··· 310 313 fragments: fragmentNames, 311 314 start, 312 315 length, 313 - } = moduleSpecifierToFragments[moduleSpecifier]; 316 + } = moduleSpecifierToFragments[moduleSpecifier]!; 314 317 const missingFragments = Array.from( 315 318 new Set(fragmentNames.filter(x => !usedFragments.has(x))) 316 319 ); ··· 348 351 }, 349 352 schema: SchemaRef, 350 353 info: ts.server.PluginCreateInfo 351 - ) => { 354 + ): ts.Diagnostic[] => { 352 355 const filename = source.fileName; 353 356 const isCallExpression = info.config.templateIsCallExpression ?? true; 354 357 ··· 429 432 if (!diag.message.includes('Unknown directive')) return true; 430 433 431 434 const [message] = diag.message.split('('); 432 - const matches = /Unknown directive "@([^)]+)"/g.exec(message); 435 + const matches = 436 + message && /Unknown directive "@([^)]+)"/g.exec(message); 433 437 if (!matches) return true; 434 - const directiveNmae = matches[1]; 435 - return !clientDirectives.has(directiveNmae); 438 + const directiveName = matches[1]; 439 + return directiveName && !clientDirectives.has(directiveName); 436 440 }) 437 441 .map(x => { 438 442 const { start, end } = x.range; ··· 440 444 // We add the start.line to account for newline characters which are 441 445 // split out 442 446 let startChar = startingPosition + start.line; 443 - for (let i = 0; i <= start.line; i++) { 447 + for (let i = 0; i <= start.line && i < lines.length; i++) { 444 448 if (i === start.line) startChar += start.character; 445 - else if (lines[i]) startChar += lines[i].length; 449 + else if (lines[i]) startChar += lines[i]!.length; 446 450 } 447 451 448 452 let endChar = startingPosition + end.line; 449 - for (let i = 0; i <= end.line; i++) { 453 + for (let i = 0; i <= end.line && i < lines.length; i++) { 450 454 if (i === end.line) endChar += end.character; 451 - else if (lines[i]) endChar += lines[i].length; 455 + else if (lines[i]) endChar += lines[i]!.length; 452 456 } 453 457 454 458 const locatedInFragment = resolvedSpans.find(x => { ··· 516 520 .flat() 517 521 .filter(Boolean) as Array<Diagnostic & { length: number; start: number }>; 518 522 519 - const tsDiagnostics = diagnostics.map(diag => ({ 520 - file: source, 521 - length: diag.length, 522 - start: diag.start, 523 - category: 524 - diag.severity === 2 525 - ? ts.DiagnosticCategory.Warning 526 - : ts.DiagnosticCategory.Error, 527 - code: 528 - typeof diag.code === 'number' 529 - ? diag.code 530 - : diag.severity === 2 531 - ? USING_DEPRECATED_FIELD_CODE 532 - : SEMANTIC_DIAGNOSTIC_CODE, 533 - messageText: diag.message.split('\n')[0], 534 - })); 523 + const tsDiagnostics = diagnostics.map( 524 + diag => 525 + ({ 526 + file: source, 527 + length: diag.length, 528 + start: diag.start, 529 + category: 530 + diag.severity === 2 531 + ? ts.DiagnosticCategory.Warning 532 + : ts.DiagnosticCategory.Error, 533 + code: 534 + typeof diag.code === 'number' 535 + ? diag.code 536 + : diag.severity === 2 537 + ? USING_DEPRECATED_FIELD_CODE 538 + : SEMANTIC_DIAGNOSTIC_CODE, 539 + messageText: diag.message.split('\n')[0], 540 + } as ts.Diagnostic) 541 + ); 535 542 536 543 if (isCallExpression) { 537 544 const usageDiagnostics =
+27 -24
packages/graphqlsp/src/fieldUsage.ts
··· 223 223 const isSomeOrEvery = 224 224 foundRef.name.text === 'every' || foundRef.name.text === 'some'; 225 225 const callExpression = foundRef.parent; 226 - let func: ts.Expression | ts.FunctionDeclaration = 226 + let func: ts.Expression | ts.FunctionDeclaration | undefined = 227 227 callExpression.arguments[0]; 228 228 229 - if (ts.isIdentifier(func)) { 229 + if (func && ts.isIdentifier(func)) { 230 230 // TODO: Scope utilities in checkFieldUsageInFile to deduplicate 231 231 const checker = info.languageService.getProgram()!.getTypeChecker(); 232 232 ··· 244 244 } 245 245 246 246 if ( 247 - ts.isFunctionDeclaration(func) || 248 - ts.isFunctionExpression(func) || 249 - ts.isArrowFunction(func) 247 + func && 248 + (ts.isFunctionDeclaration(func) || 249 + ts.isFunctionExpression(func) || 250 + ts.isArrowFunction(func)) 250 251 ) { 251 252 const param = func.parameters[isReduce ? 1 : 0]; 252 - const res = crawlScope( 253 - param.name, 254 - pathParts, 255 - allFields, 256 - source, 257 - info, 258 - true 259 - ); 260 - 261 - if ( 262 - ts.isVariableDeclaration(callExpression.parent) && 263 - !isSomeOrEvery 264 - ) { 265 - const varRes = crawlScope( 266 - callExpression.parent.name, 253 + if (param) { 254 + const res = crawlScope( 255 + param.name, 267 256 pathParts, 268 257 allFields, 269 258 source, 270 259 info, 271 260 true 272 261 ); 273 - res.push(...varRes); 274 - } 275 262 276 - return res; 263 + if ( 264 + ts.isVariableDeclaration(callExpression.parent) && 265 + !isSomeOrEvery 266 + ) { 267 + const varRes = crawlScope( 268 + callExpression.parent.name, 269 + pathParts, 270 + allFields, 271 + source, 272 + info, 273 + true 274 + ); 275 + res.push(...varRes); 276 + } 277 + 278 + return res; 279 + } 277 280 } 278 281 } else if ( 279 282 ts.isPropertyAccessExpression(foundRef) && ··· 505 508 if (loc) { 506 509 aggregatedUnusedFields.add(parentField); 507 510 if (unusedChildren[parentField]) { 508 - unusedChildren[parentField].add(unusedField); 511 + unusedChildren[parentField]!.add(unusedField); 509 512 } else { 510 513 unusedChildren[parentField] = new Set([unusedField]); 511 514 }
+7 -7
packages/graphqlsp/src/graphql/getFragmentSpreadSuggestions.ts
··· 120 120 } 121 121 122 122 for (j = 1; j <= bLength; j++) { 123 - d[0][j] = j; 123 + d[0]![j] = j; 124 124 } 125 125 126 126 for (i = 1; i <= aLength; i++) { 127 127 for (j = 1; j <= bLength; j++) { 128 128 const cost = a[i - 1] === b[j - 1] ? 0 : 1; 129 129 130 - d[i][j] = Math.min( 131 - d[i - 1][j] + 1, 132 - d[i][j - 1] + 1, 133 - d[i - 1][j - 1] + cost 130 + d[i]![j] = Math.min( 131 + d[i - 1]![j]! + 1, 132 + d[i]![j - 1]! + 1, 133 + d[i - 1]![j - 1]! + cost 134 134 ); 135 135 136 136 if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { 137 - d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + cost); 137 + d[i]![j] = Math.min(d[i]![j]!, d[i - 2]![j - 2]! + cost); 138 138 } 139 139 } 140 140 } 141 141 142 - return d[aLength][bLength]; 142 + return d[aLength]![bLength]!; 143 143 } 144 144 145 145 export type AllTypeInfo = {
+12 -5
packages/graphqlsp/src/persisted.ts
··· 89 89 foundFilename = filename; 90 90 if (callExpression.typeArguments) { 91 91 const [typeQuery] = callExpression.typeArguments; 92 - if (!ts.isTypeQueryNode(typeQuery)) return undefined; 92 + if (!typeQuery || !ts.isTypeQueryNode(typeQuery)) return undefined; 93 93 const { node: found, filename: fileName } = 94 94 getDocumentReferenceFromTypeQuery(typeQuery, filename, info); 95 95 foundNode = found; ··· 116 116 if ( 117 117 !initializer || 118 118 !ts.isCallExpression(initializer) || 119 + !initializer.arguments[0] || 119 120 !ts.isStringLiteralLike(initializer.arguments[0]) 120 - ) 121 + ) { 121 122 return undefined; 123 + } 122 124 123 125 const hash = generateHashForDocument( 124 126 info, 125 127 initializer.arguments[0], 126 128 foundFilename, 127 - ts.isArrayLiteralExpression(initializer.arguments[1]) 129 + initializer.arguments[1] && 130 + ts.isArrayLiteralExpression(initializer.arguments[1]) 128 131 ? initializer.arguments[1] 129 132 : undefined 130 133 ); ··· 183 186 fragments.forEach(fragmentDefinition => { 184 187 text = `${text}\n\n${print(fragmentDefinition)}`; 185 188 }); 186 - return createHash('sha256').update(print(parse(text))).digest('hex'); 189 + return createHash('sha256') 190 + .update(print(parse(text))) 191 + .digest('hex'); 187 192 } else { 188 193 const externalSource = getSource(info, foundFilename)!; 189 194 const { fragments } = findAllCallExpressions(externalSource, info); ··· 229 234 resolvedText = `${resolvedText}\n\n${print(fragmentDefinition)}`; 230 235 } 231 236 232 - return createHash('sha256').update(print(parse(resolvedText))).digest('hex'); 237 + return createHash('sha256') 238 + .update(print(parse(resolvedText))) 239 + .digest('hex'); 233 240 } 234 241 }; 235 242
+1
packages/graphqlsp/tsconfig.json
··· 8 8 "forceConsistentCasingInFileNames": true, 9 9 "allowJs": true, 10 10 "strict": true, 11 + "noUncheckedIndexedAccess": true, 11 12 "skipLibCheck": true 12 13 } 13 14 }
+1
tsconfig.json
··· 9 9 "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 10 10 /* Type Checking */ 11 11 "strict": true /* Enable all strict type-checking options. */, 12 + "noUncheckedIndexedAccess": true, 12 13 "skipLibCheck": true /* Skip type checking all .d.ts files. */ 13 14 } 14 15 }