diff --git a/README.md b/README.md index f1f28a1..44b07d3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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. @@ -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 diff --git a/src/filter.ts b/src/filter.ts index 3694635..47ac6bd 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -33,6 +33,7 @@ import { type ProfanityMatchRange, type ProfanitySeverity, type ProfanityTermList, + type ReadonlyProfanityFilter, } from "./types.js"; import { compileLooseLiteralPatterns, @@ -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, @@ -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), +); diff --git a/src/index.ts b/src/index.ts index ce8d52b..dc81219 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export type { ProfanitySeverity, ProfanityTaxonomyMetadata, ProfanityTermList, + ReadonlyProfanityFilter, } from "./types.js"; export type { ProfanityLanguageDictionary, diff --git a/src/types.ts b/src/types.ts index 8d77859..51cd2f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; diff --git a/tests/api.spec.ts b/tests/api.spec.ts index 1f34242..348f14d 100644 --- a/tests/api.spec.ts +++ b/tests/api.spec.ts @@ -19,6 +19,7 @@ import { type ProfanityMatchRange, type ProfanitySeverity, type ProfanityTaxonomyMetadata, + type ReadonlyProfanityFilter, russianProfanityDictionary, validateProfanityLanguageDictionary, } from "../src"; @@ -141,11 +142,38 @@ describe("public API", () => { >(); }); + it("exports the shared default filter as a read-only type", () => { + expectTypeOf(filter).toEqualTypeOf(); + 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); @@ -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"); diff --git a/tests/dist-public-api-smoke.ts b/tests/dist-public-api-smoke.ts index fd17366..5059358 100644 --- a/tests/dist-public-api-smoke.ts +++ b/tests/dist-public-api-smoke.ts @@ -17,6 +17,7 @@ import { type ProfanityMatchRange, type ProfanitySeverity, type ProfanityTaxonomyMetadata, + type ReadonlyProfanityFilter, } from "../dist/index.js"; const category: ProfanityCategory = "OBSCENE_MAT"; @@ -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."); }