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
40 changes: 20 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,23 +56,23 @@ const tenantSafeText = tenantFilter.censor("message text");
```

The default shared instance is exported as `filter` and uses the built-in strict
and loose term lists. It is mutable through `setStrict`, `setLoose`,
`addStrict`, and `addLoose`, so changes affect later calls that use the same
shared instance.
and loose term lists. It is read-only for shared use: `check`, `censor`, and
`analyze` are available, while `setStrict`, `setLoose`, `addStrict`, and
`addLoose` throw instead of mutating process-wide state.

Use `createProfanityFilter(...)` when per-request, per-tenant, or test-local
dictionaries must be isolated from the shared mutable `filter`.
dictionaries must be mutable and isolated from the shared `filter`.

### Shared Default Vs Isolated Filters

Use the exported `filter` for the package's shared built-in Russian behavior
when your code only calls `check`, `censor`, or `analyze`. It is a process-local
object: every module that imports `filter` receives the same mutable instance.
read-only object: every module that imports `filter` receives the same instance,
and mutation methods throw to protect shared dictionary state.

Do not call `setStrict`, `setLoose`, `addStrict`, or `addLoose` on the shared
`filter` for application-specific, tenant-specific, request-specific, or
test-local terms. Those calls intentionally mutate that shared instance and
therefore affect future checks performed through the same import.
Do not use the shared `filter` for application-specific, tenant-specific,
request-specific, or test-local terms. Use a factory-created filter for those
runtime dictionaries.

Use `createProfanityFilter(...)`,
`createProfanityFilterFromDictionary(dictionary)`, or
Expand Down Expand Up @@ -194,11 +194,12 @@ const looseOnly = createProfanityFilter([], ["banned"]);
const builtIn = createProfanityFilter();
```

All filter instances expose stable `name: "profanity"` plus `check`, `censor`,
`analyze`, `setStrict`, `setLoose`, `addStrict`, and `addLoose`.
Runtime mutation methods affect only the instance they are called on. Calling
them on the shared `filter` mutates shared package state; calling them on a
factory-created filter mutates only that isolated instance.
All factory-created filter instances expose stable `name: "profanity"` plus
`check`, `censor`, `analyze`, `setStrict`, `setLoose`, `addStrict`, and
`addLoose`. Runtime mutation methods affect only the instance they are called
on. The shared `filter` is typed as `ReadonlyProfanityFilter`, keeps the same
read methods, and rejects mutation methods at runtime for JavaScript consumers;
factory-created filters remain the mutable boundary.

### Language Dictionaries

Expand Down Expand Up @@ -405,9 +406,9 @@ into different compiled matcher views.
- Ranges are UTF-16 offsets into the original source string.
- Runtime dictionaries do not support caller-provided regular expressions.
- Runtime string terms do not receive taxonomy metadata.
- The shared `filter` instance is mutable and process-local; reserve its
mutation methods for deliberate global changes.
- Use factory-created filters for application-specific, tenant-specific,
- The shared `filter` instance is process-local and read-only; use its
`check`, `censor`, and `analyze` methods for built-in Russian behavior.
- Use factory-created mutable filters for application-specific, tenant-specific,
request-specific, or test-local runtime dictionary changes.
- Built-in corpus behavior is intentionally locked by compatibility tests.

Expand All @@ -427,9 +428,8 @@ Intentional public-package changes:
- The filter exposes `check(text): boolean` for boolean-only detection.
- `createProfanityFilter()` without arguments creates an instance with compiled
views of the built-in Russian dictionary.
- The exported `filter` remains a shared mutable default for backward
compatibility; factory-created filters are the recommended boundary for
isolated runtime mutation.
- The exported `filter` is a shared read-only default; factory-created filters
are the mutable boundary for isolated runtime mutation.
- Masking preserves JavaScript string length for astral code points.

## Architecture
Expand Down
28 changes: 27 additions & 1 deletion src/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
type ProfanityMatchRange,
type ProfanitySeverity,
type ProfanityTermList,
type ReadonlyProfanityFilter,
} from "./types.js";
import {
compileLooseLiteralPatterns,
Expand Down Expand Up @@ -156,6 +157,29 @@ function createFilter(state: FilterState): ProfanityFilter {
};
}

function createReadOnlyFilter(
filter: ProfanityFilter,
): ReadonlyProfanityFilter {
const readOnlyFilter = Object.freeze({
name: filter.name,
analyze: filter.analyze,
check: filter.check,
censor: filter.censor,
setStrict: rejectReadOnlyFilterMutation,
addStrict: rejectReadOnlyFilterMutation,
setLoose: rejectReadOnlyFilterMutation,
addLoose: rejectReadOnlyFilterMutation,
});

return readOnlyFilter;
}

function rejectReadOnlyFilterMutation(): never {
throw new TypeError(
"The shared profanity filter is read-only. Use createProfanityFilter() or a dictionary factory to create a mutable filter.",
);
}

function createState(
strictTerms: ProfanityTermList,
looseTerms: ProfanityTermList,
Expand Down Expand Up @@ -536,4 +560,6 @@ const isAtLeastSeverity = (
PROFANITY_SEVERITY_RANK[severity] >= PROFANITY_SEVERITY_RANK[minSeverity];

export const profanityFilter = createProfanityFilter;
export const filter = createProfanityFilter(STRICT_BASE, LOOSE_BASE);
export const filter: ReadonlyProfanityFilter = createReadOnlyFilter(
createProfanityFilter(STRICT_BASE, LOOSE_BASE),
);
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type {
ProfanitySeverity,
ProfanityTaxonomyMetadata,
ProfanityTermList,
ReadonlyProfanityFilter,
} from "./types.js";
export type {
ProfanityLanguageDictionary,
Expand Down
5 changes: 4 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,14 @@ export interface ProfanityMatchRange extends Readonly<

export const PROFANITY_FILTER_NAME = "profanity";

export interface ProfanityFilter {
export interface ReadonlyProfanityFilter {
readonly name: typeof PROFANITY_FILTER_NAME;
analyze(text: string, options?: ProfanityMatchOptions): ProfanityMatchRange[];
check(text: string, options?: ProfanityMatchOptions): boolean;
censor(text: string, options?: ProfanityMatchOptions): string;
}

export interface ProfanityFilter extends ReadonlyProfanityFilter {
setStrict(list: ProfanityTermList): void;
setLoose(list: ProfanityTermList): void;
addStrict(term: unknown): void;
Expand Down
40 changes: 40 additions & 0 deletions tests/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type ProfanityMatchRange,
type ProfanitySeverity,
type ProfanityTaxonomyMetadata,
type ReadonlyProfanityFilter,
russianProfanityDictionary,
validateProfanityLanguageDictionary,
} from "../src";
Expand Down Expand Up @@ -141,11 +142,38 @@ describe("public API", () => {
>();
});

it("exports the shared default filter as a read-only type", () => {
expectTypeOf(filter).toEqualTypeOf<ReadonlyProfanityFilter>();
expectTypeOf(createProfanityFilter()).toMatchTypeOf<{
setStrict(list: readonly unknown[]): void;
setLoose(list: readonly unknown[]): void;
addStrict(term: unknown): void;
addLoose(term: unknown): void;
}>();
});

it("exposes the default instance and the compatible factory alias", () => {
expect(filter.name).toBe(PROFANITY_FILTER_NAME);
expect(profanityFilter(["fff"], []).censor("fff ggg")).toBe("*** ggg");
});

it("keeps the shared default filter read-only", () => {
const errorMessage =
"The shared profanity filter is read-only. Use createProfanityFilter() or a dictionary factory to create a mutable filter.";

expect(Object.isFrozen(filter)).toBe(true);
expect(() => filter.addStrict("shared-only")).toThrow(errorMessage);
expect(() => filter.addLoose("sharedloose")).toThrow(errorMessage);
expect(() => filter.setStrict(["replacement-only"])).toThrow(errorMessage);
expect(() => filter.setLoose(["replacementloose"])).toThrow(errorMessage);

expect(filter.check("блядь")).toBe(true);
expect(filter.check("shared-only")).toBe(false);
expect(filter.check("s-h-a-r-e-d-l-o-o-s-e")).toBe(false);
expect(filter.check("replacement-only")).toBe(false);
expect(filter.check("r-e-p-l-a-c-e-m-e-n-t-l-o-o-s-e")).toBe(false);
});

it("exposes a stable filter name and check helper", () => {
expect(filter.name).toBe(PROFANITY_FILTER_NAME);
expect(filter.check("привет блядь")).toBe(true);
Expand Down Expand Up @@ -735,6 +763,18 @@ describe("public API", () => {
);
});

it("keeps factory-created filters mutable after the default export is read-only", () => {
const mutable = createProfanityFilter([], []);

mutable.addStrict("strict-only");
mutable.addLoose("looseonly");

expect(mutable.check("strict-only")).toBe(true);
expect(mutable.check("l-o-o-s-e-o-n-l-y")).toBe(true);
expect(filter.check("strict-only")).toBe(false);
expect(filter.check("l-o-o-s-e-o-n-l-y")).toBe(false);
});

it("keeps built-in rules active when appending runtime literals", () => {
const strict = createProfanityFilter(undefined, []);
strict.addStrict("custom");
Expand Down
6 changes: 6 additions & 0 deletions tests/dist-public-api-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type ProfanityMatchRange,
type ProfanitySeverity,
type ProfanityTaxonomyMetadata,
type ReadonlyProfanityFilter,
} from "../dist/index.js";

const category: ProfanityCategory = "OBSCENE_MAT";
Expand Down Expand Up @@ -50,15 +51,20 @@ const dictionaryFilter: ProfanityFilter =
createProfanityFilterFromDictionary(dictionary);
const compiledDictionaryFilter: ProfanityFilter =
createProfanityFilterFromCompiledDictionary(compiledDictionary);
const sharedFilter: ReadonlyProfanityFilter = filter;
const match: ProfanityMatchRange | undefined = strict.analyze(
"alpha beta gamma delta",
options,
)[0];

filter.check("plain text");
sharedFilter.censor("plain text");
dictionaryFilter.check("plain text");
compiledDictionaryFilter.check("plain text");

// @ts-expect-error The shared default filter is read-only.
filter.addStrict("not-allowed");

if (PROFANITY_FILTER_NAME !== "profanity") {
throw new Error("Unexpected filter name declaration.");
}
Expand Down