@clossys/writer 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/LICENSE +21 -0
  3. package/README.md +593 -0
  4. package/dist/addressability.d.ts +245 -0
  5. package/dist/addressability.d.ts.map +1 -0
  6. package/dist/addressability.js +567 -0
  7. package/dist/addressability.js.map +1 -0
  8. package/dist/checker.d.ts +159 -0
  9. package/dist/checker.d.ts.map +1 -0
  10. package/dist/checker.js +177 -0
  11. package/dist/checker.js.map +1 -0
  12. package/dist/cli.d.ts +149 -0
  13. package/dist/cli.d.ts.map +1 -0
  14. package/dist/cli.js +1049 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/copy-gate.d.ts +107 -0
  17. package/dist/copy-gate.d.ts.map +1 -0
  18. package/dist/copy-gate.js +132 -0
  19. package/dist/copy-gate.js.map +1 -0
  20. package/dist/fingerprint.d.ts +61 -0
  21. package/dist/fingerprint.d.ts.map +1 -0
  22. package/dist/fingerprint.js +64 -0
  23. package/dist/fingerprint.js.map +1 -0
  24. package/dist/index.d.ts +47 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +63 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/locale-coverage.d.ts +227 -0
  29. package/dist/locale-coverage.d.ts.map +1 -0
  30. package/dist/locale-coverage.js +414 -0
  31. package/dist/locale-coverage.js.map +1 -0
  32. package/dist/passage.d.ts +294 -0
  33. package/dist/passage.d.ts.map +1 -0
  34. package/dist/passage.js +463 -0
  35. package/dist/passage.js.map +1 -0
  36. package/dist/path-exclusions.d.ts +114 -0
  37. package/dist/path-exclusions.d.ts.map +1 -0
  38. package/dist/path-exclusions.js +213 -0
  39. package/dist/path-exclusions.js.map +1 -0
  40. package/dist/registry.d.ts +61 -0
  41. package/dist/registry.d.ts.map +1 -0
  42. package/dist/registry.js +64 -0
  43. package/dist/registry.js.map +1 -0
  44. package/dist/resolve.d.ts +33 -0
  45. package/dist/resolve.d.ts.map +1 -0
  46. package/dist/resolve.js +133 -0
  47. package/dist/resolve.js.map +1 -0
  48. package/dist/scan.d.ts +457 -0
  49. package/dist/scan.d.ts.map +1 -0
  50. package/dist/scan.js +1698 -0
  51. package/dist/scan.js.map +1 -0
  52. package/dist/schema.d.ts +81 -0
  53. package/dist/schema.d.ts.map +1 -0
  54. package/dist/schema.js +482 -0
  55. package/dist/schema.js.map +1 -0
  56. package/dist/types.d.ts +268 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/dist/types.js +40 -0
  59. package/dist/types.js.map +1 -0
  60. package/dist/voice/checker.d.ts +186 -0
  61. package/dist/voice/checker.d.ts.map +1 -0
  62. package/dist/voice/checker.js +483 -0
  63. package/dist/voice/checker.js.map +1 -0
  64. package/dist/voice/derivation-coverage.d.ts +168 -0
  65. package/dist/voice/derivation-coverage.d.ts.map +1 -0
  66. package/dist/voice/derivation-coverage.js +181 -0
  67. package/dist/voice/derivation-coverage.js.map +1 -0
  68. package/dist/voice/fields.d.ts +107 -0
  69. package/dist/voice/fields.d.ts.map +1 -0
  70. package/dist/voice/fields.js +144 -0
  71. package/dist/voice/fields.js.map +1 -0
  72. package/dist/voice/index.d.ts +19 -0
  73. package/dist/voice/index.d.ts.map +1 -0
  74. package/dist/voice/index.js +24 -0
  75. package/dist/voice/index.js.map +1 -0
  76. package/dist/voice/internal/parse-template.d.ts +27 -0
  77. package/dist/voice/internal/parse-template.d.ts.map +1 -0
  78. package/dist/voice/internal/parse-template.js +111 -0
  79. package/dist/voice/internal/parse-template.js.map +1 -0
  80. package/dist/voice/internal/pattern-safety.d.ts +111 -0
  81. package/dist/voice/internal/pattern-safety.d.ts.map +1 -0
  82. package/dist/voice/internal/pattern-safety.js +278 -0
  83. package/dist/voice/internal/pattern-safety.js.map +1 -0
  84. package/dist/voice/schema.d.ts +65 -0
  85. package/dist/voice/schema.d.ts.map +1 -0
  86. package/dist/voice/schema.js +535 -0
  87. package/dist/voice/schema.js.map +1 -0
  88. package/dist/voice/types.d.ts +282 -0
  89. package/dist/voice/types.d.ts.map +1 -0
  90. package/dist/voice/types.js +37 -0
  91. package/dist/voice/types.js.map +1 -0
  92. package/package.json +72 -0
  93. package/src/addressability.ts +664 -0
  94. package/src/checker.ts +293 -0
  95. package/src/cli.ts +1166 -0
  96. package/src/copy-gate.ts +197 -0
  97. package/src/fingerprint.ts +66 -0
  98. package/src/index.ts +163 -0
  99. package/src/locale-coverage.ts +512 -0
  100. package/src/passage.ts +644 -0
  101. package/src/path-exclusions.ts +265 -0
  102. package/src/registry.ts +103 -0
  103. package/src/resolve.ts +172 -0
  104. package/src/scan.ts +1959 -0
  105. package/src/schema.ts +528 -0
  106. package/src/types.ts +301 -0
  107. package/src/voice/checker.ts +590 -0
  108. package/src/voice/derivation-coverage.ts +217 -0
  109. package/src/voice/fields.ts +160 -0
  110. package/src/voice/index.ts +55 -0
  111. package/src/voice/internal/parse-template.ts +114 -0
  112. package/src/voice/internal/pattern-safety.ts +336 -0
  113. package/src/voice/schema.ts +585 -0
  114. package/src/voice/types.ts +329 -0
  115. package/templates/voice-record.template.jsonc +135 -0
@@ -0,0 +1,336 @@
1
+ /**
2
+ * The regex-safety gate for `PatternRule.pattern` (see `../types.ts`) —
3
+ * shared by `../schema.ts` (registration-time validation, when a
4
+ * `VoiceRecord` is parsed from raw data) and `../checker.ts`
5
+ * (defense-in-depth at check time, since `checkCopy` deliberately does not
6
+ * trust its `VoiceRecord` argument — see that file's own doc comment).
7
+ *
8
+ * THE POSITION THIS PACKAGE TAKES ON REGEX SAFETY, STATED ONCE, IN FULL:
9
+ * bound the pattern at REGISTRATION TIME, never at run time. There is no
10
+ * runtime timeout, no worker thread, no execution sandbox anywhere in this
11
+ * package — Node has no synchronous way to cancel a regex mid-match, and a
12
+ * background-thread-plus-timeout scheme is real infrastructure this
13
+ * package's zero-dependency, synchronous, pure-function design (see
14
+ * `../types.ts`'s header comment) has no room for. Instead, `checkPatternSafety`
15
+ * below performs a STATIC analysis of the pattern's SOURCE TEXT and refuses
16
+ * to compile — reports as a finding, never silently drops (see `../schema.ts`
17
+ * and `../checker.ts`) — any pattern this analysis cannot bound:
18
+ *
19
+ * 1. Disallowed flags. Only `i` (case-insensitive), `u` (unicode), and `s`
20
+ * (dotAll) are accepted. `g`/`y` (global/sticky) are rejected because
21
+ * this package always does its own counting pass (see
22
+ * `countPatternMatches`) — accepting a caller's own `g`/`y` would only
23
+ * invite `lastIndex`-statefulness bugs for zero benefit. `m`
24
+ * (multiline) is rejected too: this package matches a pattern against a
25
+ * single piece of copy, never multi-line source text where `^`/`$`
26
+ * anchors are meant to bind per-line — accepting `m` would silently
27
+ * change what `^`/`$` mean in exactly the case most likely to surprise
28
+ * an author.
29
+ * 2. Pattern length. `source.length > MAX_PATTERN_SOURCE_LENGTH` (200) is
30
+ * rejected outright — a voice rule is a short, auditable phrase or
31
+ * character ban, never a novel-length expression, and a hard length
32
+ * cap bounds every other check's own work too.
33
+ * 3. Backreferences (`\1`-`\9`, `\k<name>`). A well-known, independent
34
+ * source of catastrophic backtracking, and one a voice/glossary rule
35
+ * has essentially no legitimate use for — rejected outright, no
36
+ * attempt to bound them.
37
+ * 4. Oversized bounded repetition (`{m,n}` or `{m}` whose effective upper
38
+ * bound exceeds `MAX_QUANTIFIER_BOUND`, 50). A large-but-bounded repeat
39
+ * count is still a real cost multiplier, especially combined with
40
+ * other repeats elsewhere in the same pattern.
41
+ * 5. NESTED QUANTIFIERS — the classic `(a+)+` / `(a*)*` / `(.*)+` shape
42
+ * that causes real exponential-time catastrophic backtracking.
43
+ * Detected via a hand-rolled structural scan (`analyzePatternSource`
44
+ * below): any quantifier (bounded or unbounded — `*`, `+`, `{m,}`, or
45
+ * `{m,n}`) applied to a GROUP whose own top-level content already
46
+ * contains an UNBOUNDED quantifier (`*`, `+`, or `{m,}` with no upper
47
+ * bound) is rejected. Deliberately conservative in the direction of
48
+ * REJECTING more than strictly necessary: a false positive (a pattern
49
+ * that would actually have run fine) is merely annoying to its author;
50
+ * a false negative here is a hung scanner, which this package will
51
+ * never accept as the trade.
52
+ *
53
+ * WHAT THIS DOES NOT CATCH — stated honestly, not silently:
54
+ *
55
+ * - Overlapping-alternation blowup (`(a|a)+`, `(a|ab)*`) that involves no
56
+ * nested quantifier at all. A real, known ReDoS shape this static scan
57
+ * does not detect — a full fix requires real regex-AST analysis (what
58
+ * tools like `safe-regex` do), which this package's zero-dependency,
59
+ * hand-rolled-scanner design does not attempt. Bounded in practice by
60
+ * the length cap (rule 2) and the fact that this package only ever
61
+ * matches a pattern against one short piece of product/marketing copy,
62
+ * never arbitrary-length adversarial input — but this is a mitigation,
63
+ * not a guarantee.
64
+ * - Aggregate cost across many pattern rules evaluated against the same
65
+ * `copy` string. Each pattern is bounded individually; the total cost of
66
+ * a large `patterns` list is a volume concern for the record's author,
67
+ * not something this gate polices.
68
+ *
69
+ * This is a real, bounded, DOCUMENTED static gate — not a general ReDoS
70
+ * detector, and this file does not claim to be one.
71
+ */
72
+
73
+ /** Regex flags this package will construct a `RegExp` with. Anything else is rejected — see this file's top doc comment, point 1. */
74
+ export const PATTERN_ALLOWED_FLAGS: ReadonlySet<string> = new Set(["i", "u", "s"]);
75
+
76
+ /** Hard cap on `pattern.source.length` — see this file's top doc comment, point 2. */
77
+ export const MAX_PATTERN_SOURCE_LENGTH = 200;
78
+
79
+ /** Hard cap on a bounded quantifier's effective upper bound (`{m,n}`/`{m}`) — see this file's top doc comment, point 4. */
80
+ export const MAX_QUANTIFIER_BOUND = 50;
81
+
82
+ export type PatternSafetyIssue =
83
+ | "pattern-source-shape"
84
+ | "pattern-flags-shape"
85
+ | "pattern-too-long"
86
+ | "pattern-backreference"
87
+ | "pattern-quantifier-bound-too-large"
88
+ | "pattern-nested-quantifier"
89
+ | "pattern-does-not-compile";
90
+
91
+ export interface PatternSafetyOk {
92
+ ok: true;
93
+ regex: RegExp;
94
+ }
95
+
96
+ export interface PatternSafetyFailure {
97
+ ok: false;
98
+ issue: PatternSafetyIssue;
99
+ detail: string;
100
+ }
101
+
102
+ export type PatternSafetyResult = PatternSafetyOk | PatternSafetyFailure;
103
+
104
+ function fail(issue: PatternSafetyIssue, detail: string): PatternSafetyFailure {
105
+ return { ok: false, issue, detail };
106
+ }
107
+
108
+ interface GroupFrame {
109
+ /** Did THIS group's own top-level content (not a nested group's) contain an unbounded quantifier? */
110
+ hasUnboundedQuantifier: boolean;
111
+ }
112
+
113
+ /**
114
+ * Structural scan for backreferences and nested/oversized quantifiers — see
115
+ * this file's top doc comment for exactly what is and is not caught. Walks
116
+ * `source` one character at a time, tracking character-class state (`[...]`)
117
+ * and a group-nesting stack, exactly the same bounded, hand-rolled style
118
+ * `../../scan.ts`'s own tokenizer uses for JS/JSX (see that file's top doc
119
+ * comment) — no regex-of-regexes, no parser library. Returns the FIRST
120
+ * issue found, or `undefined` if the source passes this scan (compilation
121
+ * itself is checked separately, by the caller, via `new RegExp`).
122
+ */
123
+ function analyzePatternSource(source: string): PatternSafetyFailure | undefined {
124
+ const n = source.length;
125
+ let i = 0;
126
+ let inClass = false;
127
+ const stack: GroupFrame[] = [{ hasUnboundedQuantifier: false }];
128
+ // The frame belonging to the group that was JUST closed by the immediately
129
+ // preceding ")" — cleared by every other kind of token, since only a
130
+ // quantifier reading this value RIGHT AFTER a ")" means "applies to that
131
+ // group".
132
+ let lastClosedGroupFrame: GroupFrame | undefined;
133
+
134
+ const applyUnboundedQuantifier = (): PatternSafetyFailure | undefined => {
135
+ if (lastClosedGroupFrame?.hasUnboundedQuantifier) {
136
+ return fail(
137
+ "pattern-nested-quantifier",
138
+ `a quantifier at offset ${i} applies to a group that itself contains an unbounded quantifier — this is the classic catastrophic-backtracking shape (e.g. "(a+)+"); rejected without attempting to run it`,
139
+ );
140
+ }
141
+ const top = stack[stack.length - 1];
142
+ if (top) top.hasUnboundedQuantifier = true;
143
+ return undefined;
144
+ };
145
+
146
+ while (i < n) {
147
+ const c = source[i] as string;
148
+
149
+ if (inClass) {
150
+ if (c === "\\") {
151
+ i += 2;
152
+ } else if (c === "]") {
153
+ inClass = false;
154
+ i++;
155
+ } else {
156
+ i++;
157
+ }
158
+ lastClosedGroupFrame = undefined;
159
+ continue;
160
+ }
161
+
162
+ if (c === "\\") {
163
+ const next = source[i + 1];
164
+ if (next !== undefined && next >= "1" && next <= "9") {
165
+ return fail(
166
+ "pattern-backreference",
167
+ `backreference "\\${next}" at offset ${i} — a known independent source of catastrophic backtracking, rejected outright`,
168
+ );
169
+ }
170
+ if (next === "k" && source[i + 2] === "<") {
171
+ return fail(
172
+ "pattern-backreference",
173
+ `named backreference "\\k<...>" at offset ${i} — a known independent source of catastrophic backtracking, rejected outright`,
174
+ );
175
+ }
176
+ i += 2;
177
+ lastClosedGroupFrame = undefined;
178
+ continue;
179
+ }
180
+
181
+ if (c === "[") {
182
+ inClass = true;
183
+ i++;
184
+ lastClosedGroupFrame = undefined;
185
+ continue;
186
+ }
187
+
188
+ if (c === "(") {
189
+ stack.push({ hasUnboundedQuantifier: false });
190
+ i++;
191
+ lastClosedGroupFrame = undefined;
192
+ continue;
193
+ }
194
+
195
+ if (c === ")") {
196
+ const closed = stack.pop();
197
+ if (!closed) return fail("pattern-does-not-compile", `unmatched ")" at offset ${i}`);
198
+ i++;
199
+ lastClosedGroupFrame = closed;
200
+ continue;
201
+ }
202
+
203
+ if (c === "*" || c === "+") {
204
+ const issue = applyUnboundedQuantifier();
205
+ if (issue) return issue;
206
+ i++;
207
+ if (source[i] === "?") i++; // lazy modifier — still unbounded, does not change the analysis
208
+ lastClosedGroupFrame = undefined;
209
+ continue;
210
+ }
211
+
212
+ if (c === "?") {
213
+ // A bounded (0-1) quantifier on the previous atom/group. Never marks
214
+ // the enclosing frame as unbounded, and a group with an inner
215
+ // unbounded quantifier wrapped as "(...)?" (applied at most once) is
216
+ // NOT the nested-quantifier shape this scan exists to catch.
217
+ i++;
218
+ lastClosedGroupFrame = undefined;
219
+ continue;
220
+ }
221
+
222
+ if (c === "{") {
223
+ const close = source.indexOf("}", i);
224
+ const body = close === -1 ? undefined : source.slice(i + 1, close);
225
+ const match = body === undefined ? null : /^(\d+)(,(\d*))?$/.exec(body);
226
+ if (!match) {
227
+ // Not valid `{n}`/`{n,}`/`{n,m}` quantifier syntax — JS treats a
228
+ // lone "{" that doesn't form one of these as a literal character,
229
+ // not an error, so this scan does the same.
230
+ i++;
231
+ lastClosedGroupFrame = undefined;
232
+ continue;
233
+ }
234
+ const hasComma = match[2] !== undefined;
235
+ const upperText = match[3];
236
+ const unbounded = hasComma && (upperText === undefined || upperText === "");
237
+ if (unbounded) {
238
+ const issue = applyUnboundedQuantifier();
239
+ if (issue) return issue;
240
+ } else {
241
+ const upper = hasComma ? Number(upperText) : Number(match[1]);
242
+ if (upper > MAX_QUANTIFIER_BOUND) {
243
+ return fail(
244
+ "pattern-quantifier-bound-too-large",
245
+ `quantifier "{${body}}" at offset ${i} has an upper bound of ${upper}, exceeding the maximum of ${MAX_QUANTIFIER_BOUND}`,
246
+ );
247
+ }
248
+ if (lastClosedGroupFrame?.hasUnboundedQuantifier) {
249
+ return fail(
250
+ "pattern-nested-quantifier",
251
+ `quantifier "{${body}}" at offset ${i} applies to a group that itself contains an unbounded quantifier`,
252
+ );
253
+ }
254
+ }
255
+ i = (close as number) + 1;
256
+ if (source[i] === "?") i++;
257
+ lastClosedGroupFrame = undefined;
258
+ continue;
259
+ }
260
+
261
+ i++;
262
+ lastClosedGroupFrame = undefined;
263
+ }
264
+
265
+ if (stack.length !== 1) {
266
+ return fail("pattern-does-not-compile", `unbalanced "(" — ${stack.length - 1} group(s) never closed`);
267
+ }
268
+ return undefined;
269
+ }
270
+
271
+ /**
272
+ * The full registration-time (and defense-in-depth check-time) safety gate
273
+ * for one `VoicePattern`. `pattern` is `unknown`, not the `{ source, flags
274
+ * }` shape it will usually already be — this is called from both
275
+ * `../schema.ts` (validating raw, untrusted JSON, where `pattern` may be
276
+ * anything) and `../checker.ts` (re-checking a typed `VoiceRecord` this
277
+ * package's own `checkCopy` does not otherwise trust — see that file's doc
278
+ * comment; a hostile or malformed in-memory record could still hand this a
279
+ * non-object). Never throws, for any input.
280
+ */
281
+ export function checkPatternSafety(pattern: unknown): PatternSafetyResult {
282
+ if (typeof pattern !== "object" || pattern === null || Array.isArray(pattern)) {
283
+ return fail("pattern-source-shape", `pattern must be an object with a "source" string, got ${JSON.stringify(pattern)}`);
284
+ }
285
+ const source = (pattern as { source?: unknown }).source;
286
+ if (typeof source !== "string" || source.length === 0) {
287
+ return fail("pattern-source-shape", `pattern.source must be a non-empty string, got ${JSON.stringify(source)}`);
288
+ }
289
+ if (source.length > MAX_PATTERN_SOURCE_LENGTH) {
290
+ return fail(
291
+ "pattern-too-long",
292
+ `pattern.source is ${source.length} character(s), exceeding the maximum of ${MAX_PATTERN_SOURCE_LENGTH}`,
293
+ );
294
+ }
295
+
296
+ const flags = (pattern as { flags?: unknown }).flags;
297
+ if (flags !== undefined) {
298
+ if (typeof flags !== "string") {
299
+ return fail("pattern-flags-shape", `pattern.flags must be a string when present, got ${JSON.stringify(flags)}`);
300
+ }
301
+ for (const flag of flags) {
302
+ if (!PATTERN_ALLOWED_FLAGS.has(flag)) {
303
+ return fail(
304
+ "pattern-flags-shape",
305
+ `pattern.flags "${flags}" contains "${flag}", which is not one of the allowed flags (${[...PATTERN_ALLOWED_FLAGS].join(", ")}) — see this file's top doc comment for why "g"/"y"/"m" are rejected`,
306
+ );
307
+ }
308
+ }
309
+ }
310
+
311
+ const structuralIssue = analyzePatternSource(source);
312
+ if (structuralIssue) return structuralIssue;
313
+
314
+ try {
315
+ const regex = new RegExp(source, flags ?? "");
316
+ return { ok: true, regex };
317
+ } catch (error) {
318
+ return fail("pattern-does-not-compile", error instanceof Error ? error.message : String(error));
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Runs an ALREADY-SAFE (passed `checkPatternSafety`) regex against `copy`,
324
+ * counting non-overlapping matches — the pattern-rule analog of
325
+ * `../checker.ts`'s own `countMatches`, except this never wraps the source
326
+ * in `\b...\b`: a pattern rule's whole point is that the caller's regex IS
327
+ * the expression, unmodified. Always adds "g" for counting, regardless of
328
+ * what flags were validated — "g" is never an allowed caller-supplied flag
329
+ * (see `checkPatternSafety`), so this is the one place it is ever added,
330
+ * and only for this internal counting pass.
331
+ */
332
+ export function countPatternMatches(copy: string, regex: RegExp): number {
333
+ const counting = new RegExp(regex.source, regex.flags.includes("g") ? regex.flags : `${regex.flags}g`);
334
+ const matches = copy.match(counting);
335
+ return matches ? matches.length : 0;
336
+ }