@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.
- package/CHANGELOG.md +196 -0
- package/LICENSE +21 -0
- package/README.md +593 -0
- package/dist/addressability.d.ts +245 -0
- package/dist/addressability.d.ts.map +1 -0
- package/dist/addressability.js +567 -0
- package/dist/addressability.js.map +1 -0
- package/dist/checker.d.ts +159 -0
- package/dist/checker.d.ts.map +1 -0
- package/dist/checker.js +177 -0
- package/dist/checker.js.map +1 -0
- package/dist/cli.d.ts +149 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1049 -0
- package/dist/cli.js.map +1 -0
- package/dist/copy-gate.d.ts +107 -0
- package/dist/copy-gate.d.ts.map +1 -0
- package/dist/copy-gate.js +132 -0
- package/dist/copy-gate.js.map +1 -0
- package/dist/fingerprint.d.ts +61 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +64 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/locale-coverage.d.ts +227 -0
- package/dist/locale-coverage.d.ts.map +1 -0
- package/dist/locale-coverage.js +414 -0
- package/dist/locale-coverage.js.map +1 -0
- package/dist/passage.d.ts +294 -0
- package/dist/passage.d.ts.map +1 -0
- package/dist/passage.js +463 -0
- package/dist/passage.js.map +1 -0
- package/dist/path-exclusions.d.ts +114 -0
- package/dist/path-exclusions.d.ts.map +1 -0
- package/dist/path-exclusions.js +213 -0
- package/dist/path-exclusions.js.map +1 -0
- package/dist/registry.d.ts +61 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +64 -0
- package/dist/registry.js.map +1 -0
- package/dist/resolve.d.ts +33 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +133 -0
- package/dist/resolve.js.map +1 -0
- package/dist/scan.d.ts +457 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +1698 -0
- package/dist/scan.js.map +1 -0
- package/dist/schema.d.ts +81 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +482 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +268 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +40 -0
- package/dist/types.js.map +1 -0
- package/dist/voice/checker.d.ts +186 -0
- package/dist/voice/checker.d.ts.map +1 -0
- package/dist/voice/checker.js +483 -0
- package/dist/voice/checker.js.map +1 -0
- package/dist/voice/derivation-coverage.d.ts +168 -0
- package/dist/voice/derivation-coverage.d.ts.map +1 -0
- package/dist/voice/derivation-coverage.js +181 -0
- package/dist/voice/derivation-coverage.js.map +1 -0
- package/dist/voice/fields.d.ts +107 -0
- package/dist/voice/fields.d.ts.map +1 -0
- package/dist/voice/fields.js +144 -0
- package/dist/voice/fields.js.map +1 -0
- package/dist/voice/index.d.ts +19 -0
- package/dist/voice/index.d.ts.map +1 -0
- package/dist/voice/index.js +24 -0
- package/dist/voice/index.js.map +1 -0
- package/dist/voice/internal/parse-template.d.ts +27 -0
- package/dist/voice/internal/parse-template.d.ts.map +1 -0
- package/dist/voice/internal/parse-template.js +111 -0
- package/dist/voice/internal/parse-template.js.map +1 -0
- package/dist/voice/internal/pattern-safety.d.ts +111 -0
- package/dist/voice/internal/pattern-safety.d.ts.map +1 -0
- package/dist/voice/internal/pattern-safety.js +278 -0
- package/dist/voice/internal/pattern-safety.js.map +1 -0
- package/dist/voice/schema.d.ts +65 -0
- package/dist/voice/schema.d.ts.map +1 -0
- package/dist/voice/schema.js +535 -0
- package/dist/voice/schema.js.map +1 -0
- package/dist/voice/types.d.ts +282 -0
- package/dist/voice/types.d.ts.map +1 -0
- package/dist/voice/types.js +37 -0
- package/dist/voice/types.js.map +1 -0
- package/package.json +72 -0
- package/src/addressability.ts +664 -0
- package/src/checker.ts +293 -0
- package/src/cli.ts +1166 -0
- package/src/copy-gate.ts +197 -0
- package/src/fingerprint.ts +66 -0
- package/src/index.ts +163 -0
- package/src/locale-coverage.ts +512 -0
- package/src/passage.ts +644 -0
- package/src/path-exclusions.ts +265 -0
- package/src/registry.ts +103 -0
- package/src/resolve.ts +172 -0
- package/src/scan.ts +1959 -0
- package/src/schema.ts +528 -0
- package/src/types.ts +301 -0
- package/src/voice/checker.ts +590 -0
- package/src/voice/derivation-coverage.ts +217 -0
- package/src/voice/fields.ts +160 -0
- package/src/voice/index.ts +55 -0
- package/src/voice/internal/parse-template.ts +114 -0
- package/src/voice/internal/pattern-safety.ts +336 -0
- package/src/voice/schema.ts +585 -0
- package/src/voice/types.ts +329 -0
- 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
|
+
}
|