Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/audit-log-private-helpers.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/drizzle-column-arithmetic.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/rca-internal-helpers-exempt.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
Expand Up @@ -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(...)`.
Expand Down
55 changes: 53 additions & 2 deletions eslint-plugin-audit-log/src/rules/mutating-service-must-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -88,14 +134,16 @@ export const mutatingServiceMustAuditRule = createRule<
fileGlob: DEFAULT_FILE_GLOB,
mutatingPrefixes: [...DEFAULT_MUTATING_PREFIXES],
auditCallees: [...DEFAULT_AUDIT_CALLEES],
allowFunctions: []
allowFunctions: [],
includePrivate: false
}
],
create(context, [options]) {
const fileGlob = options.fileGlob ?? DEFAULT_FILE_GLOB;
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 })) {
Expand Down Expand Up @@ -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 });
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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" }]
}
]
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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];
Expand Down Expand Up @@ -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<RuleOptions, MessageIds>({
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],
Expand All @@ -55,9 +91,12 @@ export const noRawSqlOutsideAllowlistRule = createRule<RuleOptions, MessageIds>(
"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 {};
Expand Down Expand Up @@ -93,6 +132,10 @@ export const noRawSqlOutsideAllowlistRule = createRule<RuleOptions, MessageIds>(
return;
}

if (allowColumnArithmetic && isColumnArithmetic(node)) {
return;
}

context.report({
node,
messageId: "noRawSql"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
Expand Down Expand Up @@ -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: `
Expand Down
Loading
Loading