diff --git a/.npmignore b/.npmignore index ac83e5a..6a17121 100644 --- a/.npmignore +++ b/.npmignore @@ -1,7 +1,7 @@ .editorconfig .travis.yml node_modules/ -src/ +rules/ dangerfile.ts tsconfig.json tslint.json diff --git a/package.json b/package.json index fe08618..3e0fffd 100755 --- a/package.json +++ b/package.json @@ -2,9 +2,12 @@ "name": "tslint-plugin-relay", "version": "0.0.1", "description": "", - "main": "build/index.js", + "main": "build/tslint-base.json", "author": "Ash Furrow & Art.sy Inc", "license": "MIT", + "peerDependencies": { + "graphql": "^14.0.2" + }, "devDependencies": { "@types/jest": "^23.3.2", "@types/node": "^10.11.0", @@ -17,12 +20,14 @@ "ts-jest": "^23.10.1", "ts-node": "^7.0.1", "tslint": "^5.11.0", - "typescript": "^3.0.3" + "tslint-config-prettier": "^1.15.0", + "typescript": "^3.0.3", + "graphql": "^14.0.2" }, "scripts": { "type-check": "tsc --noEmit", - "build": "tsc", - "lint": "tslint 'src/**/*.{ts,tsx}'", + "build": "tsc && cp tslint-*.json build/", + "lint": "tslint 'rules/**/*.{ts,tsx}'", "release": "release-it" }, "jest": { @@ -32,7 +37,7 @@ "printWidth": 115, "semi": false, "singleQuote": false, - "trailingComma": "es5", + "trailingComma": "all", "bracketSpacing": true, "proseWrap": "always" }, diff --git a/rules/relayOperationGenericsRule.ts b/rules/relayOperationGenericsRule.ts new file mode 100644 index 0000000..b07a178 --- /dev/null +++ b/rules/relayOperationGenericsRule.ts @@ -0,0 +1,173 @@ +import * as GraphQL from "graphql" +import * as Lint from "tslint" +import * as ts from "typescript" + +export class Rule extends Lint.Rules.AbstractRule { + apply(sourceFile) { + return this.applyWithWalker(new RelayOperationGenericsWalker(sourceFile, this.getOptions())) + } +} + +class RelayOperationGenericsWalker extends Lint.RuleWalker { + imports: ts.ImportDeclaration[] = [] + + visitImportDeclaration(node: ts.ImportDeclaration) { + this.imports.push(node) + super.visitImportDeclaration(node) + } + + visitJsxSelfClosingElement(node: ts.JsxSelfClosingElement) { + if (node.tagName.getText() === "QueryRenderer") { + for (const property of node.attributes.properties) { + if ( + property.kind === ts.SyntaxKind.JsxAttribute && + property.name.getText() === "query" && + property.initializer + ) { + const initializer = property.initializer + if (initializer.kind === ts.SyntaxKind.JsxExpression) { + const expression = initializer.expression + if (expression) { + this.visitOperationConfiguration(node, expression, node.tagName) + } + } else { + this.addFailureAtNode(initializer, "expected a graphql`…` tagged-template expression") + } + break + } + } + } + + super.visitJsxSelfClosingElement(node) + } + + visitCallExpression(node: ts.CallExpression) { + const functionName = node.expression as ts.Identifier + if (functionName.text === "commitMutation") { + const config = node.arguments[1] as undefined | ts.ObjectLiteralExpression + if (config && config.kind === ts.SyntaxKind.ObjectLiteralExpression) { + // any = this.visitOperationConfiguration(node, config, functionName) + for (const property of config.properties) { + if (property.name && property.name.getText() === "mutation") { + if (property.kind === ts.SyntaxKind.PropertyAssignment) { + this.visitOperationConfiguration(node, property.initializer, functionName) + } else { + // TODO: Need to expand parsing if we want to support e.g. + // short-hand property assignment. + this.addFailureAtNode(property, "use traditional assignment for mutation query") + } + break + } + } + } + } + + super.visitCallExpression(node) + } + + visitOperationConfiguration( + node: ts.CallExpression | ts.JsxSelfClosingElement, + expression: ts.Expression, + functionOrTagName: ts.Node, + ) { + const taggedTemplate = expression as ts.TaggedTemplateExpression + if ( + taggedTemplate.kind === ts.SyntaxKind.TaggedTemplateExpression && + taggedTemplate.tag.getText() === "graphql" + ) { + const typeArgument = node.typeArguments && (node.typeArguments[0] as ts.TypeReferenceNode) + if (!typeArgument) { + const operationName = getOperationName(taggedTemplate) + if (operationName) { + const fixes = this.createFixes(functionOrTagName.getEnd(), 0, `<${operationName}>`, operationName) + this.addFailureAtNode(functionOrTagName, "missing operation type parameter", fixes) + } + } else { + const operationName = getOperationName(taggedTemplate) + if ( + operationName && + (typeArgument.kind !== ts.SyntaxKind.TypeReference || typeArgument.typeName.getText() !== operationName) + ) { + const fixes = this.createFixes( + typeArgument.getStart(), + typeArgument.getWidth(), + operationName, + operationName, + ) + this.addFailureAtNode( + typeArgument, + `expected operation type parameter to be \`${operationName}\``, + fixes, + ) + } + } + } else { + this.addFailureAtNode(taggedTemplate, "expected a graphql`…` tagged-template") + } + } + + createFixes(start: number, width: number, replacement: string, operationName: string): Lint.Replacement[] { + const fixes = [new Lint.Replacement(start, width, replacement)] + if (!this.hasImportForOperation(operationName)) { + fixes.push(this.importDeclarationFixForOperation(operationName)) + } + return fixes + } + + importPathForOperation(operationName: string) { + const options = this.getOptions()[0] || { + artifactDirectory: "__generated__", + makeRelative: false, + } + if (options.makeRelative) { + throw new Error("[relayOperationGenericsRule] Making import declarations relative is not implemented yet.") + } + return `${options.artifactDirectory}/${operationName}.graphql` + } + + importDeclarationFixForOperation(operationName: string) { + const path = this.importPathForOperation(operationName) + const importDeclaration = `import { ${operationName} } from "${path}"\n` + + const lastImport = this.imports[this.imports.length - 1] + + let start = 0 + if (lastImport) { + start = lastImport.getEnd() + 1 + } + + return new Lint.Replacement(start, 0, importDeclaration) + } + + hasImportForOperation(operationName: string) { + const importPath = this.importPathForOperation(operationName) + + return this.imports.some(node => { + const path = node.moduleSpecifier as ts.StringLiteral + if (path.text === importPath && node.importClause) { + const namedBindings = node.importClause.namedBindings as ts.NamedImports + if (namedBindings) { + return namedBindings.elements.some(element => element.name.getText() === operationName) + } + } + return false + }) + } +} + +function getOperationName(taggedTemplate: ts.TaggedTemplateExpression): string | null { + const template = taggedTemplate.template.getFullText() + // Strip backticks + const source = template.substring(1, template.length - 1) + + const ast = GraphQL.parse(source) + let queryName = null + GraphQL.visit(ast, { + OperationDefinition(node) { + queryName = node.name.value + return GraphQL.BREAK + }, + }) + + return queryName +} diff --git a/src/_tests/index.test.ts b/src/_tests/index.test.ts deleted file mode 100755 index 3380472..0000000 --- a/src/_tests/index.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import hello from "../" - -describe("hello", () => { - it("does something", () => { - hello() - }) -}) diff --git a/src/index.ts b/src/index.ts deleted file mode 100755 index 4e3e874..0000000 --- a/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function hello() { - console.log("Hello World") // tslint:disable-line -} diff --git a/tsconfig.json b/tsconfig.json index a96f4e8..e3cb5da 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "es6", "module": "commonjs", - "rootDir": "src", + "rootDir": "rules", "outDir": "build", "allowJs": false, "pretty": true, @@ -11,8 +11,8 @@ }, "lib":["es2017"], "include": [ - "src/**/*.ts", - "src/**/*.tsx", + "rules/**/*.ts", + "rules/**/*.tsx", "dangerfile.ts" ], "exclude": [ diff --git a/tslint-base.json b/tslint-base.json new file mode 100644 index 0000000..7872c82 --- /dev/null +++ b/tslint-base.json @@ -0,0 +1,3 @@ +{ + "rulesDirectory": "./build" +} diff --git a/tslint-config.json b/tslint-config.json new file mode 100644 index 0000000..21beef3 --- /dev/null +++ b/tslint-config.json @@ -0,0 +1,6 @@ +{ + "extends": "./tslint-base.json", + "rules": { + "tslint-plugin-relay": true + } +} diff --git a/tslint.json b/tslint.json index c46b007..040c854 100755 --- a/tslint.json +++ b/tslint.json @@ -1,6 +1,7 @@ { "extends": [ - "tslint:recommended" + "tslint:recommended", + "tslint-config-prettier" ], "rules": { "arrow-parens": false, @@ -8,6 +9,7 @@ true, "never-prefix" ], + "no-console": false, "max-classes-per-file": [ false ], @@ -16,6 +18,7 @@ "check-accessor", "check-constructor" ], + "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore"], // Disabled till there’s an auto-fixer for this. // https://github.com/palantir/tslint/blob/master/src/rules/objectLiteralSortKeysRule.ts "object-literal-sort-keys": false, diff --git a/wallaby.js b/wallaby.js index e2aff22..aca6cd0 100644 --- a/wallaby.js +++ b/wallaby.js @@ -2,12 +2,12 @@ module.exports = function(wallaby) { return { files: [ "tsconfig.json", - "src/**/*.ts?(x)", - "src/**/*.snap", - "src/**/*.json", - "!src/**/*.test.ts?(x)", + "rules/**/*.ts?(x)", + "rules/**/*.snap", + "rules/**/*.json", + "!rules/**/*.test.ts?(x)", ], - tests: ["src/**/*.test.ts?(x)"], + tests: ["rules/**/*.test.ts?(x)"], env: { type: "node", diff --git a/yarn.lock b/yarn.lock index bb2e781..d4fe177 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2492,6 +2492,13 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.2: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" integrity sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg= +graphql@^14.0.2: + version "14.0.2" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.0.2.tgz#7dded337a4c3fd2d075692323384034b357f5650" + integrity sha512-gUC4YYsaiSJT1h40krG3J+USGlwhzNTXSb4IOZljn9ag5Tj+RkoXrWp+Kh7WyE3t1NCfab5kzCuxBIvOMERMXw== + dependencies: + iterall "^1.2.2" + growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" @@ -3212,6 +3219,11 @@ isurl@^1.0.0-alpha5: has-to-string-tag-x "^1.2.0" is-object "^1.0.1" +iterall@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7" + integrity sha512-yynBb1g+RFUPY64fTrFv7nsjRrENBQJaX2UL+2Szc9REFrSNm1rpSXHGzhmAy7a9uv3vlvgBlXnf9RqmPH1/DA== + jest-changed-files@^23.4.2: version "23.4.2" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-23.4.2.tgz#1eed688370cd5eebafe4ae93d34bb3b64968fe83" @@ -5946,6 +5958,11 @@ tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ== +tslint-config-prettier@^1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/tslint-config-prettier/-/tslint-config-prettier-1.15.0.tgz#76b9714399004ab6831fdcf76d89b73691c812cf" + integrity sha512-06CgrHJxJmNYVgsmeMoa1KXzQRoOdvfkqnJth6XUkNeOz707qxN0WfxfhYwhL5kXHHbYJRby2bqAPKwThlZPhw== + tslint@^5.11.0: version "5.11.0" resolved "https://registry.yarnpkg.com/tslint/-/tslint-5.11.0.tgz#98f30c02eae3cde7006201e4c33cb08b48581eed"