From 8380c4752451ba46e3760211a325aa83b03b1638 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Mon, 14 Sep 2026 19:50:07 +0200 Subject: [PATCH] fix(rules): stop three product-fork frictions at the rule level - react-component-architecture/component-folder-structure: a .tsx file that exports nothing is an internal helper of the component beside it and owes no siblings of its own. - audit-log/mutating-service-must-audit: only the public surface is checked; module-private functions and private/protected/#name methods are body of the audited method. includePrivate restores the old scan. - drizzle-conventions/no-raw-sql-outside-allowlist: templates whose text is only arithmetic around column references (the atomic increment) are allowed; allowColumnArithmetic: false disables it. Each ships with rule-tester cases, docs and a changeset. --- .changeset/audit-log-private-helpers.md | 5 + .changeset/drizzle-column-arithmetic.md | 5 + .changeset/rca-internal-helpers-exempt.md | 5 + .../docs/rules/mutating-service-must-audit.md | 9 ++ .../src/rules/mutating-service-must-audit.ts | 55 ++++++++++- .../rules/mutating-service-must-audit.test.ts | 74 +++++++++++++++ .../rules/no-raw-sql-outside-allowlist.md | 1 + .../src/rules/noRawSqlOutsideAllowlist.ts | 51 ++++++++++- .../rules/noRawSqlOutsideAllowlist.test.ts | 53 +++++++++++ .../docs/rules/component-folder-structure.md | 6 +- .../src/rules/componentFolderStructure.ts | 21 ++++- .../rules/componentFolderStructure.test.ts | 91 +++++++++++++++---- 12 files changed, 350 insertions(+), 26 deletions(-) create mode 100644 .changeset/audit-log-private-helpers.md create mode 100644 .changeset/drizzle-column-arithmetic.md create mode 100644 .changeset/rca-internal-helpers-exempt.md diff --git a/.changeset/audit-log-private-helpers.md b/.changeset/audit-log-private-helpers.md new file mode 100644 index 0000000..372077f --- /dev/null +++ b/.changeset/audit-log-private-helpers.md @@ -0,0 +1,5 @@ +--- +"@boring-stack-pkg/eslint-plugin-audit-log": minor +--- + +`mutating-service-must-audit` now checks a service's public surface only: module-private functions (not exported) and `private` / `protected` / `#name` class methods are treated as part of the audited method's body, so an `insertDetail` helper called inside an audited `createComponent` transaction no longer needs a second `audit.record`. Exported functions, public methods and properties of service objects are still checked. Set the new `includePrivate: true` option to restore the previous behaviour. diff --git a/.changeset/drizzle-column-arithmetic.md b/.changeset/drizzle-column-arithmetic.md new file mode 100644 index 0000000..83340ed --- /dev/null +++ b/.changeset/drizzle-column-arithmetic.md @@ -0,0 +1,5 @@ +--- +"@boring-stack-pkg/eslint-plugin-drizzle-conventions": minor +--- + +`no-raw-sql-outside-allowlist` allows `sql` templates whose literal text is only arithmetic around interpolated column references, such as `sql\`${links.viewCount} + 1\``, the idiomatic atomic increment. Templates with no holes, string literals in the holes, or any other SQL text are still reported. Disable with the new `allowColumnArithmetic: false` option. diff --git a/.changeset/rca-internal-helpers-exempt.md b/.changeset/rca-internal-helpers-exempt.md new file mode 100644 index 0000000..2892ea2 --- /dev/null +++ b/.changeset/rca-internal-helpers-exempt.md @@ -0,0 +1,5 @@ +--- +"@boring-stack-pkg/eslint-plugin-react-component-architecture": patch +--- + +`component-folder-structure` no longer demands hooks, types, stories, a test and an index for a `.tsx` file that exports nothing. Such a file is an internal helper of the component beside it (an illustration, a private sub-component split out for length), not a component with a public surface; the full anatomy applies as soon as it gains an `export`. diff --git a/eslint-plugin-audit-log/docs/rules/mutating-service-must-audit.md b/eslint-plugin-audit-log/docs/rules/mutating-service-must-audit.md index c415fdd..4d0c93d 100644 --- a/eslint-plugin-audit-log/docs/rules/mutating-service-must-audit.md +++ b/eslint-plugin-audit-log/docs/rules/mutating-service-must-audit.md @@ -30,9 +30,18 @@ in some branch, the rule passes. mutatingPrefixes?: string[]; // default: ["^(create|update|delete|insert|register|approve|reject|activate|deactivate|enable|disable|complete|cancel|grant|revoke)"] auditCallees?: string[]; // default: ["auditLogService.record", "audit.record"] allowFunctions?: string[]; // default: [] + includePrivate?: boolean; // default: false } ``` +The audit obligation sits on a service's public surface. A module-private +function (not exported) or a `private` / `protected` / `#name` class method +is part of the audited method's body, so it is not checked on its own: an +`insertDetail` helper called inside an audited `createComponent` transaction +needs no second `audit.record`. Properties of an exported service object are +always surface. Set `includePrivate: true` to check every matching name +regardless of visibility. + `auditCallees` are matched as dotted accessors with a forgiving suffix match: `audit.record` matches both `audit.record(...)` and `this.audit.record(...)`. diff --git a/eslint-plugin-audit-log/src/rules/mutating-service-must-audit.ts b/eslint-plugin-audit-log/src/rules/mutating-service-must-audit.ts index ecb99b6..33d2974 100644 --- a/eslint-plugin-audit-log/src/rules/mutating-service-must-audit.ts +++ b/eslint-plugin-audit-log/src/rules/mutating-service-must-audit.ts @@ -19,6 +19,8 @@ export interface MutatingServiceMustAuditOptions { readonly mutatingPrefixes?: readonly string[]; readonly auditCallees?: readonly string[]; readonly allowFunctions?: readonly string[]; + /** Also check module-private functions and private/protected class methods (default false). */ + readonly includePrivate?: boolean; } type RuleOptions = [MutatingServiceMustAuditOptions]; @@ -47,10 +49,54 @@ const optionSchema: JSONSchema4 = { type: "array", items: { type: "string" }, uniqueItems: true - } + }, + includePrivate: { type: "boolean" } } }; +/** + * The audit obligation sits on the public surface of a service. A helper + * that only the audited method can reach (a non-exported module function or + * a private/protected class method) is part of that method's body, not a + * second mutation entry point. + */ +function isPrivateDeclaration(node: FunctionLike): boolean { + const parent = (node as { parent?: TSESTree.Node }).parent; + + if (node.type === AST_NODE_TYPES.FunctionDeclaration) { + return ( + parent === undefined || + (parent.type !== AST_NODE_TYPES.ExportNamedDeclaration && + parent.type !== AST_NODE_TYPES.ExportDefaultDeclaration) + ); + } + + if (parent === undefined) { + return false; + } + + if (parent.type === AST_NODE_TYPES.VariableDeclarator) { + const declaration = (parent as { parent?: TSESTree.Node }).parent; + const owner = (declaration as { parent?: TSESTree.Node } | undefined) + ?.parent; + + return ( + declaration?.type === AST_NODE_TYPES.VariableDeclaration && + owner?.type !== AST_NODE_TYPES.ExportNamedDeclaration + ); + } + + if (parent.type === AST_NODE_TYPES.MethodDefinition) { + return ( + parent.key.type === AST_NODE_TYPES.PrivateIdentifier || + parent.accessibility === "private" || + parent.accessibility === "protected" + ); + } + + return false; +} + type FunctionLike = | TSESTree.FunctionDeclaration | TSESTree.FunctionExpression @@ -88,7 +134,8 @@ export const mutatingServiceMustAuditRule = createRule< fileGlob: DEFAULT_FILE_GLOB, mutatingPrefixes: [...DEFAULT_MUTATING_PREFIXES], auditCallees: [...DEFAULT_AUDIT_CALLEES], - allowFunctions: [] + allowFunctions: [], + includePrivate: false } ], create(context, [options]) { @@ -96,6 +143,7 @@ export const mutatingServiceMustAuditRule = createRule< const mutatingPrefixes = options.mutatingPrefixes ?? DEFAULT_MUTATING_PREFIXES; const auditCallees = options.auditCallees ?? DEFAULT_AUDIT_CALLEES; const allowFunctions = new Set(options.allowFunctions ?? []); + const includePrivate = options.includePrivate ?? false; const relative = toPosixRelative(context.filename, context.cwd); if (!micromatch.isMatch(relative, fileGlob, { dot: true })) { @@ -156,6 +204,9 @@ export const mutatingServiceMustAuditRule = createRule< if (name === null || !nameMatches(name)) { return; } + if (!includePrivate && isPrivateDeclaration(node)) { + return; + } stack.push({ node, name, hasAudit: false }); } diff --git a/eslint-plugin-audit-log/tests/rules/mutating-service-must-audit.test.ts b/eslint-plugin-audit-log/tests/rules/mutating-service-must-audit.test.ts index 1524b64..5cfe6d6 100644 --- a/eslint-plugin-audit-log/tests/rules/mutating-service-must-audit.test.ts +++ b/eslint-plugin-audit-log/tests/rules/mutating-service-must-audit.test.ts @@ -61,6 +61,48 @@ ruleTester.run(RULE_NAME, mutatingServiceMustAuditRule, { } ` }, + { + // Module-private helper reached only through the audited export. + filename: "src/catalog/catalog.service.ts", + code: ` + async function insertDetail(tx, detail) { + await tx.insert(details).values(detail); + } + const insertScheme = async (tx, scheme) => { + await tx.insert(schemes).values(scheme); + }; + export async function createComponent(input) { + await db.transaction(async (tx) => { + await insertDetail(tx, input.detail); + await insertScheme(tx, input.scheme); + }); + await auditLogService.record({ action: "component.created" }); + } + ` + }, + { + // Private and protected class methods are body, not surface. + filename: "src/users/users.service.ts", + code: ` + class UserService { + private async insertProfile(user) { + await this.repo.insert(user); + } + protected async updateIndex(user) { + await this.search.upsert(user); + } + async #deleteShadow(user) { + await this.repo.delete(user); + } + async createUser(input) { + await this.insertProfile(input); + await this.updateIndex(input); + await this.#deleteShadow(input); + await this.audit.record({ action: "user.created" }); + } + } + ` + }, { // Exempted by allowFunctions. filename: "src/users/users.service.ts", @@ -101,6 +143,38 @@ ruleTester.run(RULE_NAME, mutatingServiceMustAuditRule, { } `, errors: [{ messageId: "mutationWithoutAudit" }] + }, + { + // Exported arrow functions are surface. + filename: "src/users/users.service.ts", + code: ` + export const updateUser = async (id, patch) => repo.update(id, patch); + `, + errors: [{ messageId: "mutationWithoutAudit" }] + }, + { + // Service objects expose every property, private or not. + filename: "src/users/users.service.ts", + code: ` + export const usersService = { + async deleteUser(id) { + return repo.delete(id); + } + }; + `, + errors: [{ messageId: "mutationWithoutAudit" }] + }, + { + // includePrivate restores the old behaviour. + filename: "src/users/users.service.ts", + options: [{ includePrivate: true }], + code: ` + async function insertDetail(tx, detail) { + await tx.insert(details).values(detail); + } + void insertDetail; + `, + errors: [{ messageId: "mutationWithoutAudit" }] } ] }); diff --git a/eslint-plugin-drizzle-conventions/docs/rules/no-raw-sql-outside-allowlist.md b/eslint-plugin-drizzle-conventions/docs/rules/no-raw-sql-outside-allowlist.md index bbeedc0..7b7eef0 100644 --- a/eslint-plugin-drizzle-conventions/docs/rules/no-raw-sql-outside-allowlist.md +++ b/eslint-plugin-drizzle-conventions/docs/rules/no-raw-sql-outside-allowlist.md @@ -31,6 +31,7 @@ export const up = sql`CREATE TABLE users (id text)`; | Option | Type | Default | Description | |--------|------|---------|-------------| | `allowFiles` | `string[]` | `["**/migrations/**", "**/raw/**"]` | Glob patterns of files where raw `sql` is allowed. Matched against the absolute filename ESLint passes to the rule. | +| `allowColumnArithmetic` | `boolean` | `true` | Allow templates whose literal text is only arithmetic (`+ - * / % ( )`, digits) around interpolated identifiers or member expressions, e.g. `sql\`${links.viewCount} + 1\``, the idiomatic atomic increment. Templates with no holes, with string literals in the holes, or with any other text are still reported. | Glob syntax supports `**`, `*`, `?` and literal segments. diff --git a/eslint-plugin-drizzle-conventions/src/rules/noRawSqlOutsideAllowlist.ts b/eslint-plugin-drizzle-conventions/src/rules/noRawSqlOutsideAllowlist.ts index 7e8d6ee..7d5919b 100644 --- a/eslint-plugin-drizzle-conventions/src/rules/noRawSqlOutsideAllowlist.ts +++ b/eslint-plugin-drizzle-conventions/src/rules/noRawSqlOutsideAllowlist.ts @@ -1,4 +1,4 @@ -import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils"; import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema"; import { createRule } from "../utils/createRule"; @@ -8,6 +8,12 @@ export const RULE_NAME = "no-raw-sql-outside-allowlist"; export interface NoRawSqlOutsideAllowlistOptions { readonly allowFiles?: readonly string[]; + /** + * Allow `sql` templates whose literal text is only arithmetic around + * interpolated column references, e.g. `sql\`${table.count} + 1\``, the + * idiomatic atomic increment (default true). + */ + readonly allowColumnArithmetic?: boolean; } type RuleOptions = [NoRawSqlOutsideAllowlistOptions]; @@ -36,17 +42,47 @@ const optionSchema: JSONSchema4 = { type: "string" }, uniqueItems: true - } + }, + allowColumnArithmetic: { type: "boolean" } } }; +const ARITHMETIC_TEXT = /^[\s\d.+\-*/%()]*$/u; + +/** + * `sql\`${col} + 1\`` carries no SQL of its own: the static text is operators + * and numbers, every hole is a column reference drizzle renders itself. That + * is the atomic counter every schema needs, not a hand-written query. + */ +function isColumnArithmetic(node: TSESTree.TaggedTemplateExpression): boolean { + const { quasis, expressions } = node.quasi; + + if (expressions.length === 0) { + return false; + } + + if ( + !expressions.every( + (expression) => + expression.type === AST_NODE_TYPES.Identifier || + expression.type === AST_NODE_TYPES.MemberExpression + ) + ) { + return false; + } + + return quasis.every((quasi) => + ARITHMETIC_TEXT.test(quasi.value.cooked ?? quasi.value.raw) + ); +} + export const noRawSqlOutsideAllowlistRule = createRule({ name: RULE_NAME, meta: { type: "problem", docs: { description: - "Disallow drizzle-orm `sql` tagged template literals outside an allowlist of files (migrations, raw queries).", + "Disallow drizzle-orm `sql` tagged template literals outside an allowlist of files (migrations, raw queries); column arithmetic such as `${col} + 1` is allowed.", recommended: true }, schema: [optionSchema], @@ -55,9 +91,12 @@ export const noRawSqlOutsideAllowlistRule = createRule( "Raw `sql` template literals are not allowed outside the configured allowlist (migrations / raw)." } }, - defaultOptions: [{ allowFiles: [...DEFAULT_ALLOW_FILES] }], + defaultOptions: [ + { allowFiles: [...DEFAULT_ALLOW_FILES], allowColumnArithmetic: true } + ], create(context, [options]) { const allowFiles = options.allowFiles ?? DEFAULT_ALLOW_FILES; + const allowColumnArithmetic = options.allowColumnArithmetic ?? true; if (matchesAnyGlob(context.filename, allowFiles)) { return {}; @@ -93,6 +132,10 @@ export const noRawSqlOutsideAllowlistRule = createRule( return; } + if (allowColumnArithmetic && isColumnArithmetic(node)) { + return; + } + context.report({ node, messageId: "noRawSql" diff --git a/eslint-plugin-drizzle-conventions/tests/rules/noRawSqlOutsideAllowlist.test.ts b/eslint-plugin-drizzle-conventions/tests/rules/noRawSqlOutsideAllowlist.test.ts index 52be7ca..bcd5774 100644 --- a/eslint-plugin-drizzle-conventions/tests/rules/noRawSqlOutsideAllowlist.test.ts +++ b/eslint-plugin-drizzle-conventions/tests/rules/noRawSqlOutsideAllowlist.test.ts @@ -6,6 +6,23 @@ import { ruleTester } from "../test-utils/ruleTester"; ruleTester.run(RULE_NAME, noRawSqlOutsideAllowlistRule, { valid: [ + { + // The idiomatic atomic increment: only arithmetic around column refs. + filename: "src/users/users.service.ts", + code: ` + import { sql } from "drizzle-orm"; + export const bump = () => + db.update(links).set({ viewCount: sql\`\${links.viewCount} + 1\` }); + ` + }, + { + filename: "src/users/users.service.ts", + code: ` + import { sql } from "drizzle-orm"; + export const rebalance = (seats) => + db.update(plans).set({ used: sql\`(\${plans.used} - \${seats}) * 2\` }); + ` + }, { filename: "src/db/migrations/2026-01-init.ts", code: ` @@ -90,6 +107,42 @@ ruleTester.run(RULE_NAME, noRawSqlOutsideAllowlistRule, { } ], invalid: [ + { + // Text beyond arithmetic is a query, not an expression. + filename: "src/users/users.service.ts", + code: ` + import { sql } from "drizzle-orm"; + export const q = sql\`\${links.viewCount} + 1 WHERE 1=1\`; + `, + errors: [{ messageId: "noRawSql" }] + }, + { + // A string literal in the hole is not a column reference. + filename: "src/users/users.service.ts", + code: ` + import { sql } from "drizzle-orm"; + export const q = sql\`\${links.slug} || \${"x"}\`; + `, + errors: [{ messageId: "noRawSql" }] + }, + { + // No holes at all is plain SQL text. + filename: "src/users/users.service.ts", + code: ` + import { sql } from "drizzle-orm"; + export const q = sql\`1 + 1\`; + `, + errors: [{ messageId: "noRawSql" }] + }, + { + filename: "src/users/users.service.ts", + options: [{ allowColumnArithmetic: false }], + code: ` + import { sql } from "drizzle-orm"; + export const q = sql\`\${links.viewCount} + 1\`; + `, + errors: [{ messageId: "noRawSql" }] + }, { filename: "src/users/users.service.ts", code: ` diff --git a/eslint-plugin-react-component-architecture/docs/rules/component-folder-structure.md b/eslint-plugin-react-component-architecture/docs/rules/component-folder-structure.md index ba152e6..6ac5692 100644 --- a/eslint-plugin-react-component-architecture/docs/rules/component-folder-structure.md +++ b/eslint-plugin-react-component-architecture/docs/rules/component-folder-structure.md @@ -37,7 +37,9 @@ interface ComponentFolderStructureOptions { ## Detection -The rule triggers on `.tsx` files whose filename starts with an uppercase letter and contains a default export. It checks the parent directory for required siblings. +The rule triggers on `.tsx` files whose filename starts with an uppercase letter and that export something. It checks the parent directory for required siblings. + +A `.tsx` file that exports nothing is treated as an internal helper of the component beside it (an illustration, a private sub-component split out for length) and owes no siblings of its own. Once it gains an `export`, it is a component with a public surface and the full anatomy applies. ## Limitations @@ -51,4 +53,4 @@ None — create the missing sibling files manually. ## Version -0.1.0 +0.1.0; files without exports exempt since 0.3.1 diff --git a/eslint-plugin-react-component-architecture/src/rules/componentFolderStructure.ts b/eslint-plugin-react-component-architecture/src/rules/componentFolderStructure.ts index 0a3d730..8766d9d 100644 --- a/eslint-plugin-react-component-architecture/src/rules/componentFolderStructure.ts +++ b/eslint-plugin-react-component-architecture/src/rules/componentFolderStructure.ts @@ -1,5 +1,6 @@ import { existsSync } from "fs"; import { dirname, join } from "path"; +import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils"; import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema"; import { createRule } from "../utils/createRule"; @@ -46,6 +47,20 @@ const optionSchema: JSONSchema4 = { } }; +/** + * A `.tsx` file that exports nothing is an internal helper of the component + * next to it (an illustration, a private sub-component), not a component + * with its own public surface, so it owes no hooks/types/stories/test. + */ +function exportsAnything(program: TSESTree.Program): boolean { + return program.body.some( + (statement) => + statement.type === AST_NODE_TYPES.ExportNamedDeclaration || + statement.type === AST_NODE_TYPES.ExportDefaultDeclaration || + statement.type === AST_NODE_TYPES.ExportAllDeclaration + ); +} + export const componentFolderStructureRule = createRule< RuleOptions, MessageIds @@ -55,7 +70,7 @@ export const componentFolderStructureRule = createRule< type: "problem", docs: { description: - "Enforce required sibling files in component folders (hooks, types, stories, test, index)", + "Enforce required sibling files in component folders (hooks, types, stories, test, index); files that export nothing are internal helpers and exempt", recommended: true }, schema: [optionSchema], @@ -93,6 +108,10 @@ export const componentFolderStructureRule = createRule< return { "Program:exit"(node) { + if (!exportsAnything(node)) { + return; + } + const dir = dirname(filename); const requiredSiblings = options.requiredSiblings ?? DEFAULT_SIBLINGS; diff --git a/eslint-plugin-react-component-architecture/tests/rules/componentFolderStructure.test.ts b/eslint-plugin-react-component-architecture/tests/rules/componentFolderStructure.test.ts index 3fd2558..09d79e5 100644 --- a/eslint-plugin-react-component-architecture/tests/rules/componentFolderStructure.test.ts +++ b/eslint-plugin-react-component-architecture/tests/rules/componentFolderStructure.test.ts @@ -1,25 +1,82 @@ -import { describe, it, expect } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { afterAll } from "vitest"; import { RULE_NAME, componentFolderStructureRule } from "../../src/rules/componentFolderStructure"; -import { RuleTester } from "@typescript-eslint/rule-tester"; -import * as parser from "@typescript-eslint/parser"; +import { ruleTester } from "../test-utils/ruleTester"; -// Use RuleTester for basic structure, but it doesn't support filesystem checks well -// So we test the rule directly +// The rule checks siblings on disk, so the cases point at a real directory: +// `Complete/` carries every required sibling, `Bare/` carries none. It lives +// under the package root because the flat-config RuleTester only lints files +// inside cwd, and outside `tests/`, which the rule ignores by default. +const root = mkdtempSync(join(process.cwd(), ".rca-folder-structure-")); +const complete = join(root, "src/components/core/Complete"); +const bare = join(root, "src/components/core/Bare"); -describe(RULE_NAME, () => { - it("should require component folder structure", () => { - // Note: Full filesystem testing requires fixtures which are complex to set up - // This is a placeholder demonstrating the rule exists - expect(componentFolderStructureRule).toBeDefined(); - expect(componentFolderStructureRule.meta.type).toBe("problem"); - }); +mkdirSync(complete, { recursive: true }); +mkdirSync(bare, { recursive: true }); - it("should have proper rule metadata", () => { - expect(componentFolderStructureRule.meta.docs?.description).toContain( - "component" - ); - }); +for (const sibling of [ + "Complete.hooks.ts", + "Complete.types.ts", + "Complete.stories.tsx", + "Complete.test.ts", + "index.ts" +]) { + writeFileSync(join(complete, sibling), "export {};\n"); +} + +afterAll(() => { + rmSync(root, { recursive: true, force: true }); +}); + +ruleTester.run(RULE_NAME, componentFolderStructureRule, { + valid: [ + { + // Every sibling present. + filename: join(complete, "Complete.tsx"), + code: `export const Complete = () =>
;` + }, + { + // Exports nothing: an internal helper of the component beside it, + // not a component with a public surface of its own. + filename: join(bare, "BareArt.tsx"), + code: ` + const BareArt = () => ; + void BareArt; + ` + }, + { + // Lowercase basename is not a component file. + filename: join(bare, "helpers.tsx"), + code: `export const helper = () =>
;` + }, + { + filename: join(bare, "Bare.test.tsx"), + code: `export const x = 1;` + } + ], + invalid: [ + { + filename: join(bare, "Bare.tsx"), + code: `export const Bare = () =>
;`, + errors: [ + { + messageId: "missingSiblings", + data: { + name: "Bare", + missing: + "Bare.hooks.ts, Bare.types.ts, Bare.stories.tsx, Bare.test.ts, index.ts" + } + } + ] + }, + { + filename: join(bare, "Bare.tsx"), + code: `export default function Bare() { return
; }`, + errors: [{ messageId: "missingSiblings" }] + } + ] });