diff --git a/.changeset/kysely-embedded-fragment-types.md b/.changeset/kysely-embedded-fragment-types.md new file mode 100644 index 00000000..0c8122db --- /dev/null +++ b/.changeset/kysely-embedded-fragment-types.md @@ -0,0 +1,9 @@ +--- +"@ts-safeql/plugin-kysely": minor +"@ts-safeql/plugin-utils": minor +"@ts-safeql/eslint-plugin": patch +--- + +Validate the `` annotation on raw `sql` fragments embedded in Kysely query-builder chains. + +SafeQL now checks that annotation against the type the database returns and autofixes it on a mismatch. A selection like ``sql`name || bio`.as("credit_line")`` whose column is `string` gets flagged; a ``.where(sql`bio is not null`)`` condition gets corrected to `boolean`. Conditions accept both `SqlBool` and `boolean`, and fragments wrapped in parentheses or `as` are checked like bare ones. diff --git a/demos/plugin-kysely/src/builder.ts b/demos/plugin-kysely/src/builder.ts index e22d17a9..46f62483 100644 --- a/demos/plugin-kysely/src/builder.ts +++ b/demos/plugin-kysely/src/builder.ts @@ -20,7 +20,7 @@ export function builderSelectExpr() { export function builderAggregate() { return db .selectFrom("person") - .select(sql`count(*)`.as("total")) + .select(sql`count(*)`.as("total")) .execute(); } diff --git a/packages/eslint-plugin/src/rules/check-sql.rule.ts b/packages/eslint-plugin/src/rules/check-sql.rule.ts index 46816631..2ec8f789 100644 --- a/packages/eslint-plugin/src/rules/check-sql.rule.ts +++ b/packages/eslint-plugin/src/rules/check-sql.rule.ts @@ -2,7 +2,10 @@ import { ResolvedTarget } from "@ts-safeql/generate"; import { PluginManager, matchesQueryNodeSelector, + type IncorrectTypeAnnotationReport, type PluginResolvedTarget, + type ResolvedQueryTypeCheckContext, + type ResolvedQueryTypeCheckResult, type QueryNodeSelector, type SafeQLPlugin, type QuerySourceMapEntry, @@ -87,6 +90,9 @@ type CheckNode = TSESTree.Node; type TerminalCallQuery = { text: string; sourcemaps: QuerySourceMapEntry[]; + typeCheck?: ( + ctx: ResolvedQueryTypeCheckContext, + ) => readonly ResolvedQueryTypeCheckResult[] | undefined; }; function check(params: { @@ -271,6 +277,7 @@ function resolveQueryFromPlugins(params: { return { text: result.text, sourcemaps: result.sourcemaps, + typeCheck: result.typeCheck, }; } @@ -401,6 +408,18 @@ function resolvePluginTargetMatch( return undefined; } +function makeGetComparableString( + connection: RuleOptionConnection, +): (target: PluginResolvedTarget) => string { + return (target) => + getResolvedTargetComparableString({ + target: target as ExpectedResolvedTarget, + nullAsOptional: connection.nullAsOptional ?? false, + nullAsUndefined: connection.nullAsUndefined ?? false, + inferLiterals: connection.inferLiterals ?? defaultInferLiteralOptions, + }); +} + function reportPluginTypeCheck(params: { context: RuleContext; tag: TSESTree.TaggedTemplateExpression; @@ -412,30 +431,51 @@ function reportPluginTypeCheck(params: { }): void { const { context, tag, connection, checker, parser, output, typeCheck } = params; - const nullAsOptional = connection.nullAsOptional ?? false; - const nullAsUndefined = connection.nullAsUndefined ?? false; - const enforceType = connection.enforceType ?? "fix"; - - const report = typeCheck({ + const result = typeCheck({ node: tag, output, checker, parser, sourceCode: context.sourceCode, - getComparableString: (target) => - getResolvedTargetComparableString({ - target: target as ExpectedResolvedTarget, - nullAsOptional, - nullAsUndefined, - inferLiterals: connection.inferLiterals ?? defaultInferLiteralOptions, - }), + getComparableString: makeGetComparableString(connection), }); - if (!report) return; + if (result) { + reportTypeCheckResult({ context, connection, result, defaultNode: tag.tag }); + } +} + +function isIncorrectTypeAnnotationReport( + result: ResolvedQueryTypeCheckResult, +): result is IncorrectTypeAnnotationReport { + return "kind" in result && result.kind === "incorrect-type-annotation"; +} + +// Dispatches whatever a plugin's `typeCheck` returns: a structured annotation mismatch routes +// through the core's standard incorrect-annotation report; a free-form report becomes a plugin error. +function reportTypeCheckResult(params: { + context: RuleContext; + connection: RuleOptionConnection; + result: ResolvedQueryTypeCheckResult; + defaultNode: TSESTree.Node; +}): void { + const { context, connection, result, defaultNode } = params; + + if (isIncorrectTypeAnnotationReport(result)) { + return reportIncorrectTypeAnnotations({ + context, + typeParameter: result.typeParameter, + expected: result.expected, + actual: result.actual, + enforceType: connection.enforceType, + }); + } + + const enforceType = connection.enforceType ?? "fix"; - const reportNode = report.node ?? tag.tag; - const reportData = { error: report.message }; - const reportFixData = report.fix; + const reportNode = result.node ?? defaultNode; + const reportData = { error: result.message }; + const reportFixData = result.fix; const reportFix = reportFixData ? (fixer: TSESLint.RuleFixer) => fixer.replaceText(reportFixData.node, reportFixData.text) : undefined; @@ -512,7 +552,7 @@ function reportCheck(params: { }), E.bindW("query", ({ parser, checker }) => overrideQuery !== undefined - ? E.right(overrideQuery) + ? E.right({ text: overrideQuery.text, sourcemaps: overrideQuery.sourcemaps }) : tag.type === "TaggedTemplateExpression" ? mapTemplateLiteralToQueryText( tag.quasi, @@ -605,10 +645,19 @@ function reportCheck(params: { }); // A terminal taking no `` infers its row type from the builder's own - // schema, so it needs no annotation check; only the embedded raw `sql` - // (validated above) matters. A `` terminal falls through below. + // schema; any embedded type checks are delegated to the plugin via + // `ResolvedQuery.typeCheck`. if (tag.type === "CallExpression" && !calleeAcceptsTypeArgument(tag, checker, parser)) { - return; + return reportResolvedQueryTypeCheck({ + context, + connection, + tag, + output: result.output, + typeCheck: overrideQuery?.typeCheck, + checker, + parser, + reservedTypes, + }); } const isMissingTypeAnnotations = queryTypeParameter === undefined; @@ -681,7 +730,45 @@ function reportCheck(params: { ); } -// True when the terminal declares its own `` (annotation model); false when the row type comes from the receiver. +function reportResolvedQueryTypeCheck(params: { + context: RuleContext; + connection: RuleOptionConnection; + tag: TSESTree.CallExpression; + output: ResolvedTarget | null; + typeCheck?: ( + ctx: ResolvedQueryTypeCheckContext, + ) => readonly ResolvedQueryTypeCheckResult[] | undefined; + checker: ts.TypeChecker; + parser: ParserServices; + reservedTypes: Set; +}): void { + const { context, connection, tag, output, typeCheck, checker, parser, reservedTypes } = params; + + if (typeCheck === undefined) { + return; + } + + const results = typeCheck({ + terminal: tag, + output: output as PluginResolvedTarget | null, + checker, + parser, + sourceCode: context.sourceCode, + getComparableString: makeGetComparableString(connection), + resolveExpectedType: (typeNode) => + getResolvedTargetByTypeNode({ + checker, + parser, + typeNode, + reservedTypes, + }) as PluginResolvedTarget | null, + }); + + for (const result of results ?? []) { + reportTypeCheckResult({ context, connection, result, defaultNode: tag }); + } +} + function calleeAcceptsTypeArgument( node: TSESTree.CallExpression, checker: ts.TypeChecker, diff --git a/packages/plugin-utils/src/index.contract.test.ts b/packages/plugin-utils/src/index.contract.test.ts index 1be315e3..bab472ed 100644 --- a/packages/plugin-utils/src/index.contract.test.ts +++ b/packages/plugin-utils/src/index.contract.test.ts @@ -1,9 +1,10 @@ +import type { ParserServices, TSESLint } from "@typescript-eslint/utils"; import type ts from "typescript"; -import type { ParserServices } from "@typescript-eslint/utils"; import type { QuerySourceMapEntry, ResolvedQuery, + ResolvedQueryTypeCheckContext, SafeQLPlugin, ResolveQueryContext, QueryNodeSelector, @@ -55,6 +56,17 @@ const _testPlugin: SafeQLPlugin = { }, }; +const _typeCheckContext: ResolvedQueryTypeCheckContext = { + terminal: {} as ResolvedQueryTypeCheckContext["terminal"], + output: null, + checker: {} as ts.TypeChecker, + parser: {} as ParserServices, + sourceCode: {} as TSESLint.SourceCode, + getComparableString: () => "string", + resolveExpectedType: () => null, +}; + void _assertResolvedQueryType; +void _typeCheckContext; void _queryContext; void _testPlugin; diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 1dcf2125..2f8bbbaa 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -73,11 +73,40 @@ export function matchesQueryNodeSelector( ); } -export type ResolvedQuery = { +// Passed to `ResolvedQuery.typeCheck`, the deferred check the core runs once the query's row +// type is known. A builder plugin uses it to validate the `` a user wrote on an embedded +// `sql` fragment against the type the database actually produced. +export interface ResolvedQueryTypeCheckContext { + terminal: TSESTree.CallExpression; + output: PluginResolvedTarget | null; + checker: ts.TypeChecker; + parser: ParserServices; + sourceCode: Readonly; + getComparableString(target: PluginResolvedTarget): string; + // Resolve a user-written `` into the same shape the database output uses, so the two are comparable. + resolveExpectedType(typeNode: TSESTree.TypeNode): PluginResolvedTarget | null; +} + +// A wrong `` annotation. The core renders it like any other incorrect annotation: its +// standard message plus an autofix that rewrites the type parameter to `actual`. +export interface IncorrectTypeAnnotationReport { + kind: "incorrect-type-annotation"; + typeParameter: TSESTree.TSTypeParameterInstantiation; + expected: string | null; + actual: string | null; +} + +export type ResolvedQueryTypeCheckResult = TypeCheckReport | IncorrectTypeAnnotationReport; + +export interface ResolvedQuery { kind: "sql"; text: string; sourcemaps: QuerySourceMapEntry[]; -}; + // A query can embed several fragments, so the check reports one result per problem (none → `undefined`). + typeCheck?: ( + ctx: ResolvedQueryTypeCheckContext, + ) => readonly ResolvedQueryTypeCheckResult[] | undefined; +} // The freshly-created, empty shadow database to apply the project's migrations to. export interface MigrateContext { diff --git a/packages/plugins/kysely/src/builder-type-check.ts b/packages/plugins/kysely/src/builder-type-check.ts new file mode 100644 index 00000000..d60d4141 --- /dev/null +++ b/packages/plugins/kysely/src/builder-type-check.ts @@ -0,0 +1,115 @@ +import type { TSESTree } from "@typescript-eslint/utils"; +import type { + IncorrectTypeAnnotationReport, + PluginResolvedTarget, + ResolvedQueryTypeCheckContext, +} from "@ts-safeql/plugin-utils"; +import ts from "typescript"; + +// An embedded `sql` fragment whose `` the builder cannot verify on its own: +// a selection (`sql`...`.as("alias")`) or a `where`-like condition (always `boolean`). +export type TypedBuilderFragment = + | { kind: "column"; alias: string; tag: ts.TaggedTemplateExpression } + | { kind: "condition"; tag: ts.TaggedTemplateExpression }; + +const BOOLEAN: PluginResolvedTarget = { kind: "type", value: "boolean" }; + +export function createBuilderTypeCheck( + fragments: TypedBuilderFragment[], +): (ctx: ResolvedQueryTypeCheckContext) => IncorrectTypeAnnotationReport[] | undefined { + return (ctx) => { + const reports = fragments + .map((fragment) => checkFragment(fragment, ctx)) + .filter((report): report is IncorrectTypeAnnotationReport => report !== undefined); + + return reports.length > 0 ? reports : undefined; + }; +} + +function checkFragment( + fragment: TypedBuilderFragment, + ctx: ResolvedQueryTypeCheckContext, +): IncorrectTypeAnnotationReport | undefined { + // `get` is typed non-null but returns `undefined` for a node absent from the map. + const tag: TSESTree.Node | undefined = ctx.parser.tsNodeToESTreeNodeMap.get(fragment.tag); + if (tag === undefined || tag.type !== "TaggedTemplateExpression") { + return undefined; + } + + const typeParameter = tag.typeArguments; + const annotation = typeParameter?.params[0]; + if (typeParameter === undefined || annotation === undefined) { + return undefined; + } + + const actual = actualTypeOf(fragment, ctx.output); + const expected = expectedTypeOf(fragment, annotation, ctx); + if (actual === null || expected === null || isComparablyEqual(expected, actual, ctx)) { + return undefined; + } + + return { + kind: "incorrect-type-annotation", + typeParameter, + expected: ctx.getComparableString(expected), + actual: ctx.getComparableString(actual), + }; +} + +function actualTypeOf( + fragment: TypedBuilderFragment, + output: PluginResolvedTarget | null, +): PluginResolvedTarget | null { + if (fragment.kind === "condition") { + return BOOLEAN; + } + + if (output?.kind !== "object") { + return null; + } + + return output.value.find(([key]) => key === fragment.alias)?.[1] ?? null; +} + +function expectedTypeOf( + fragment: TypedBuilderFragment, + annotation: TSESTree.TypeNode, + ctx: ResolvedQueryTypeCheckContext, +): PluginResolvedTarget | null { + // Kysely accepts `SqlBool`, `boolean`, or a boolean intersection in a condition; collapse them + // to `boolean` so an annotation the database considers correct isn't flagged on its spelling. + if (fragment.kind === "condition" && isBooleanAnnotation(annotation, ctx)) { + return BOOLEAN; + } + + return ctx.resolveExpectedType(annotation); +} + +function isBooleanAnnotation( + annotation: TSESTree.TypeNode, + ctx: ResolvedQueryTypeCheckContext, +): boolean { + const tsNode = ctx.parser.esTreeNodeToTSNodeMap.get(annotation); + return isBooleanType(ctx.checker.getTypeAtLocation(tsNode)); +} + +// Kysely conditions accept any boolean-ish annotation — `boolean`, the `SqlBool` alias, a branded +// `boolean & {}`, or a union that includes a boolean — all of which the database produces as `boolean`. +function isBooleanType(type: ts.Type): boolean { + if ((type.flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) !== 0) { + return true; + } + + return type.isUnionOrIntersection() && type.types.some(isBooleanType); +} + +// Mirrors the core's annotation comparison, which canonicalizes literal quotes before comparing. +function isComparablyEqual( + expected: PluginResolvedTarget, + actual: PluginResolvedTarget, + ctx: ResolvedQueryTypeCheckContext, +): boolean { + const canonical = (target: PluginResolvedTarget) => + ctx.getComparableString(target).replace(/'/g, '"'); + return canonical(expected) === canonical(actual); +} diff --git a/packages/plugins/kysely/src/plugin.integration.test.ts b/packages/plugins/kysely/src/plugin.integration.test.ts index e910476d..96434cb2 100644 --- a/packages/plugins/kysely/src/plugin.integration.test.ts +++ b/packages/plugins/kysely/src/plugin.integration.test.ts @@ -224,6 +224,27 @@ ${code}`; options: withBuilderConnection(databaseName), code: kSql(`db.selectFrom("person").select("id").where(sql\`bio is not null\`).execute();`), }, + { + name: "embedded raw sql in .where() with SqlBool is validated", + options: withBuilderConnection(databaseName), + code: kSql( + `db.selectFrom("person").select("id").where(sql\`bio is not null\`).execute();`, + ), + }, + { + name: "embedded raw sql in .where() with boolean is validated", + options: withBuilderConnection(databaseName), + code: kSql( + `db.selectFrom("person").select("id").where(sql\`bio is not null\`).execute();`, + ), + }, + { + name: "typed fragment used as a binary where operand is not forced to boolean", + options: withBuilderConnection(databaseName), + code: kSql( + `db.selectFrom("person").select("id").where(sql\`length(name)\`, ">", 3).execute();`, + ), + }, { name: "value interpolation in embedded sql is a bound param", options: withBuilderConnection(databaseName), @@ -252,6 +273,53 @@ db.selectFrom("person").select(sql\`\${sql.ref(col)}\`.as("x")).execute();`, }, ], invalid: [ + { + name: "wrong column type in embedded sql select is detected", + options: withBuilderConnection(databaseName), + code: kSql( + `db.selectFrom("person").select(sql\`name || ' — ' || coalesce(bio, 'uncredited')\`.as("credit_line")).execute();`, + ), + output: kSql( + `db.selectFrom("person").select(sql\`name || ' — ' || coalesce(bio, 'uncredited')\`.as("credit_line")).execute();`, + ), + errors: [{ messageId: "incorrectTypeAnnotations", line: 4, column: 36 }], + }, + { + name: "wrong type in embedded sql where is detected", + options: withBuilderConnection(databaseName), + code: kSql( + `db.selectFrom("person").select("id").where(sql\`bio is not null\`).execute();`, + ), + output: kSql( + `db.selectFrom("person").select("id").where(sql\`bio is not null\`).execute();`, + ), + errors: [{ messageId: "incorrectTypeAnnotations", line: 4, column: 48 }], + }, + { + name: "wrong type in a parenthesized embedded sql fragment is detected", + options: withBuilderConnection(databaseName), + code: kSql( + `db.selectFrom("person").select((sql\`upper(first_name)\`).as("shout")).execute();`, + ), + output: kSql( + `db.selectFrom("person").select((sql\`upper(first_name)\`).as("shout")).execute();`, + ), + errors: [{ messageId: "incorrectTypeAnnotations", line: 4, column: 37 }], + }, + { + name: "every wrong typed fragment in a query is reported", + options: withBuilderConnection(databaseName), + code: kSql( + `db.selectFrom("person").select([sql\`upper(first_name)\`.as("a"), sql\`upper(name)\`.as("b")]).execute();`, + ), + output: kSql( + `db.selectFrom("person").select([sql\`upper(first_name)\`.as("a"), sql\`upper(name)\`.as("b")]).execute();`, + ), + errors: [ + { messageId: "incorrectTypeAnnotations" }, + { messageId: "incorrectTypeAnnotations" }, + ], + }, { name: "nonexistent column in embedded sql is detected (squiggle on the fragment)", options: withBuilderConnection(databaseName), diff --git a/packages/plugins/kysely/src/plugin.ts b/packages/plugins/kysely/src/plugin.ts index 7d2e66bf..b85605da 100644 --- a/packages/plugins/kysely/src/plugin.ts +++ b/packages/plugins/kysely/src/plugin.ts @@ -22,6 +22,7 @@ import { type TargetMatch, } from "@ts-safeql/plugin-utils"; import ts from "typescript"; +import { createBuilderTypeCheck, type TypedBuilderFragment } from "./builder-type-check"; import { migrate } from "./migrate"; type KyselyPluginConfig = { @@ -168,7 +169,7 @@ function resolveBuilderQuery( const sourceFile = tsNode.getSourceFile(); const chain = tsNode.expression; - const state: RenderState = { hasEmbeddedSql: false, fragments: [] }; + const state: RenderState = { hasEmbeddedSql: false, fragments: [], typedFragments: [] }; const compileText = buildBuilderCompileText(chain, context.checker, sourceFile, state); if (compileText === undefined) { return skipBuilderQuery( @@ -196,6 +197,9 @@ function resolveBuilderQuery( kind: "sql", text: resultSql, sourcemaps: buildBuilderSourcemaps({ state, resultSql, tsNode, sourceFile }), + ...(state.typedFragments.length > 0 + ? { typeCheck: createBuilderTypeCheck(state.typedFragments) } + : {}), }; } @@ -338,6 +342,7 @@ function createBuilderContext(): Record { interface RenderState { hasEmbeddedSql: boolean; fragments: Array<{ start: number; end: number }>; + typedFragments: TypedBuilderFragment[]; } function buildBuilderCompileText( @@ -374,7 +379,7 @@ function renderBuilderChain( // A statically-constructed Kysely plugin, e.g. `.withPlugin(new CamelCasePlugin())`. if (ts.isNewExpression(unwrapped)) { - return renderPluginConstruction(unwrapped, checker, sourceFile); + return renderPluginConstruction(unwrapped, checker); } if (ts.isPropertyAccessExpression(unwrapped)) { @@ -411,7 +416,6 @@ function renderBuilderChain( function renderPluginConstruction( node: ts.NewExpression, checker: ts.TypeChecker, - sourceFile: ts.SourceFile, ): string | undefined { if (!ts.isIdentifier(node.expression) || !allowedBuilderPlugins.has(node.expression.text)) { return undefined; @@ -518,7 +522,7 @@ function renderArgumentValue( } if (ts.isNewExpression(unwrapped)) { - return renderPluginConstruction(unwrapped, checker, sourceFile); + return renderPluginConstruction(unwrapped, checker); } const value = ast.getStaticValue({ node: unwrapped, checker: checker }); @@ -559,6 +563,12 @@ function renderSqlExpressionChain( if (isKyselySqlTaggedTemplate(u, checker)) { state.hasEmbeddedSql = true; state.fragments.push({ start: u.getStart(sourceFile), end: u.getEnd() }); + + const typedFragment = classifyTypedFragment(u, checker); + if (typedFragment !== undefined) { + state.typedFragments.push(typedFragment); + } + return renderEmbeddedSqlFragment(u.template, checker, sourceFile); } @@ -759,6 +769,103 @@ function buildTemplateSQL( return sql; } +function classifyTypedFragment( + node: ts.TaggedTemplateExpression, + checker: ts.TypeChecker, +): TypedBuilderFragment | undefined { + if (node.typeArguments === undefined || node.typeArguments.length === 0) { + return undefined; + } + + const alias = getSelectionAlias(node, checker); + if (alias !== undefined) { + return { kind: "column", alias, tag: node }; + } + + if (isConditionFragment(node)) { + return { kind: "condition", tag: node }; + } + + return undefined; +} + +// Climb the transparent wrappers (parens, `as`, `!`) the render path also sees through, so a +// wrapped `sql` fragment — `.where((sql`...`))`, `(sql`...`).as("x")` — classifies like a bare one. +function climbTransparentWrappers(node: ts.Node): ts.Node { + let current = node; + while ( + current.parent && + (ts.isParenthesizedExpression(current.parent) || + ts.isAsExpression(current.parent) || + ts.isNonNullExpression(current.parent)) && + current.parent.expression === current + ) { + current = current.parent; + } + return current; +} + +function isConditionFragment(node: ts.TaggedTemplateExpression): boolean { + let current = climbTransparentWrappers(node); + let parent = current.parent; + + while (parent && ts.isPropertyAccessExpression(parent) && parent.expression === current) { + current = parent; + parent = parent.parent; + } + + if (!parent || !ts.isCallExpression(parent)) { + return false; + } + + // Only the single-argument expression form — `.where(sql`...`)` — is itself the boolean + // condition. In the binary form `.where(lhs, op, rhs)` a fragment is an operand, not the + // condition, so its `` must not be forced to `boolean`. + const isSoleArgument = + parent.arguments.length === 1 && + (parent.arguments[0] === current || ast.unwrap({ node: parent.arguments[0] }) === node); + if (!isSoleArgument) { + return false; + } + + const callee = parent.expression; + if (!ts.isPropertyAccessExpression(callee)) { + return false; + } + + return whereLikeMethods.has(callee.name.text); +} + +function getSelectionAlias( + node: ts.TaggedTemplateExpression, + checker: ts.TypeChecker, +): string | undefined { + // `sql`...`.as("alias")` — the template is the receiver of `.as`. + const fragment = climbTransparentWrappers(node); + const asAccess = fragment.parent; + if ( + !asAccess || + !ts.isPropertyAccessExpression(asAccess) || + asAccess.expression !== fragment || + asAccess.name.text !== "as" + ) { + return undefined; + } + + const asCall = asAccess.parent; + if (!asCall || !ts.isCallExpression(asCall) || asCall.expression !== asAccess) { + return undefined; + } + + const aliasArg = asCall.arguments[0]; + if (aliasArg === undefined) { + return undefined; + } + + const alias = ast.getStaticValue({ node: aliasArg, checker: checker }); + return typeof alias === "string" ? alias : undefined; +} + function isFragmentUsage(tsNode: ts.TaggedTemplateExpression): boolean { if (tsNode.parent && ts.isTemplateSpan(tsNode.parent)) { return true; diff --git a/turbo.json b/turbo.json index aaddf148..51b2b79a 100644 --- a/turbo.json +++ b/turbo.json @@ -13,7 +13,9 @@ "dependsOn": ["^build"], "inputs": ["src/**/*.ts", "test/**/*.ts"] }, - "lint": {}, + "lint": { + "dependsOn": ["^build"] + }, "publint": { "dependsOn": ["build"], "inputs": ["package.json", "dist/**"]