eval-quality 3.0.0 → 3.2.0

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 (43) hide show
  1. package/README.md +4 -1
  2. package/dist/application/index.d.ts +4 -0
  3. package/dist/application/index.js +2 -0
  4. package/dist/core/emit/emit.js +4 -2
  5. package/dist/core/preflight/reduce.d.ts +1 -1
  6. package/dist/core/preflight/reduce.js +5 -2
  7. package/dist/core/schemas/evaluator-configuration.d.ts +9 -0
  8. package/dist/core/schemas/evaluator-configuration.js +9 -0
  9. package/dist/core/schemas/evidence-artifact.d.ts +9 -0
  10. package/dist/core/schemas/evidence-artifact.js +9 -0
  11. package/dist/core/schemas/isolation-manifest.d.ts +18 -0
  12. package/dist/core/schemas/isolation-manifest.js +18 -0
  13. package/dist/core/schemas/preflight-verdict.d.ts +9 -0
  14. package/dist/core/schemas/preflight-verdict.js +9 -0
  15. package/dist/core/schemas/private-artifact-manifest.d.ts +10 -0
  16. package/dist/core/schemas/private-artifact-manifest.js +10 -0
  17. package/dist/core/schemas/scoring-policy.d.ts +11 -0
  18. package/dist/core/schemas/scoring-policy.js +11 -0
  19. package/dist/core/schemas/sealed-evaluator-brief.d.ts +12 -0
  20. package/dist/core/schemas/sealed-evaluator-brief.js +12 -0
  21. package/dist/core/schemas/sealed-run-record.d.ts +11 -0
  22. package/dist/core/schemas/sealed-run-record.js +11 -0
  23. package/dist/core/seal/seal.js +4 -5
  24. package/dist/gates/audit-lockfile-age.mjs +392 -0
  25. package/dist/gates/check-dependency-direction.js +303 -0
  26. package/dist/gates/check-doc-claims.js +1012 -0
  27. package/dist/gates/check-doc-counts.js +408 -0
  28. package/dist/gates/check-doc-invocations.mjs +618 -0
  29. package/dist/gates/check-licenses.mjs +378 -0
  30. package/dist/gates/consumer-pattern.js +104 -0
  31. package/dist/gates/dependency-direction.js +555 -0
  32. package/dist/gates/discover-source-files.js +44 -0
  33. package/dist/gates/gate-config.js +415 -0
  34. package/dist/gates/gates-cli.js +607 -0
  35. package/dist/gates/lineage-ownership.js +364 -0
  36. package/dist/gates/module-value.js +187 -0
  37. package/dist/gates/package-boundary.js +277 -0
  38. package/dist/gates/scanned-paths.js +110 -0
  39. package/dist/gates/token-scan.js +203 -0
  40. package/dist/index.d.ts +11 -1
  41. package/dist/index.js +20 -1
  42. package/dist/testing/probe-conformance.d.ts +23 -18
  43. package/package.json +24 -11
@@ -0,0 +1,378 @@
1
+ // Holds every entry in a resolved lockfile against an allowlist of SPDX
2
+ // identifiers, and fails closed on an entry whose `resolved` is not the registry
3
+ // tarball for that entry's own name and version.
4
+ //
5
+ // Reads `package-lock.json` directly rather than walking `node_modules`: the
6
+ // lockfile records a `license` field for every entry, including optional
7
+ // platform binaries never installed on this runner's OS/CPU (Biome and friends
8
+ // ship one lock entry per platform). Walking node_modules would silently miss
9
+ // every foreign-platform entry. Needs no install.
10
+ //
11
+ // A pure scanner over a parsed lockfile and the data its caller supplies. It
12
+ // carries no allowlist, no policy and no exception of its own, so this
13
+ // repository's licence policy is data in `eval-quality.config.json` exactly as a
14
+ // consumer's is. `scripts/gates-cli.ts` is the entry point that reads that file,
15
+ // resolves the policy for each lockfile, and decides which exceptions still
16
+ // hold.
17
+ const REGISTRY_PREFIX = 'https://registry.npmjs.org/';
18
+ /**
19
+ * `error.code` on the refusal a caller repairs by pointing the gate at a real
20
+ * lockfile. `audit-lockfile-age.mjs` exports the same string and
21
+ * `tests/architecture/published-gates.test.ts` holds the two equal: neither gate
22
+ * may import from `node_modules` and there is no module between them, so the
23
+ * constant is declared twice for the reason `WINDOW_DAYS_DEFAULT` is.
24
+ */
25
+ export const LOCKFILE_SHAPE_ERROR = 'EVAL_QUALITY_LOCKFILE_SHAPE';
26
+ function refuseLockfileShape(message) {
27
+ const error = new Error(message);
28
+ error.code = LOCKFILE_SHAPE_ERROR;
29
+ return error;
30
+ }
31
+ // The one URL the public registry serves `name@version` from. A scoped name's
32
+ // tarball basename is the segment after the slash, so `@scope/pkg` at 1.0.0 is
33
+ // `https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz`.
34
+ function registryTarballUrl(name, version) {
35
+ const basename = name.startsWith('@')
36
+ ? name.slice(name.indexOf('/') + 1)
37
+ : name;
38
+ return `${REGISTRY_PREFIX}${name}/-/${basename}-${version}.tgz`;
39
+ }
40
+ // Strips a single layer of balanced outer parentheses at a time, e.g. "(MIT OR Apache-2.0)" ->
41
+ // "MIT OR Apache-2.0". Only strips when the opening paren's match is the expression's final
42
+ // character (a true outer wrap), not when parens merely appear inside, e.g. "(MIT) OR (ISC)".
43
+ function stripOuterParens(expr) {
44
+ let s = expr.trim();
45
+ while (s.startsWith('(') && s.endsWith(')')) {
46
+ let depth = 0;
47
+ let wrapsWhole = true;
48
+ for (let i = 0; i < s.length; i++) {
49
+ if (s[i] === '(')
50
+ depth++;
51
+ else if (s[i] === ')') {
52
+ depth--;
53
+ if (depth === 0 && i !== s.length - 1) {
54
+ wrapsWhole = false;
55
+ break;
56
+ }
57
+ }
58
+ }
59
+ if (!wrapsWhole)
60
+ break;
61
+ s = s.slice(1, -1).trim();
62
+ }
63
+ return s;
64
+ }
65
+ // Splits `expr` on top-level occurrences of `token` (e.g. " OR ", " AND "), ignoring occurrences
66
+ // nested inside parentheses.
67
+ function splitTopLevel(expr, token) {
68
+ const parts = [];
69
+ let depth = 0;
70
+ let start = 0;
71
+ for (let i = 0; i < expr.length; i++) {
72
+ const ch = expr[i];
73
+ if (ch === '(')
74
+ depth++;
75
+ else if (ch === ')')
76
+ depth--;
77
+ else if (depth === 0 && expr.slice(i, i + token.length) === token) {
78
+ parts.push(expr.slice(start, i));
79
+ i += token.length - 1;
80
+ start = i + 1;
81
+ }
82
+ }
83
+ parts.push(expr.slice(start));
84
+ return parts;
85
+ }
86
+ // Recursively evaluates an SPDX-ish licence expression: OR passes if any operand is allowlisted,
87
+ // AND passes only if every operand is. A bare identifier (including a "X WITH exception" compound,
88
+ // UNLICENSED, or anything unparseable) passes only via exact allowlist membership, so those all fail
89
+ // closed without special-casing.
90
+ function isAllowed(licenseExpr, allowlist) {
91
+ if (!licenseExpr || typeof licenseExpr !== 'string')
92
+ return false;
93
+ const stripped = stripOuterParens(licenseExpr.trim());
94
+ if (stripped === '')
95
+ return false;
96
+ const orParts = splitTopLevel(stripped, ' OR ');
97
+ if (orParts.length > 1)
98
+ return orParts.some((part) => isAllowed(part, allowlist));
99
+ const andParts = splitTopLevel(stripped, ' AND ');
100
+ if (andParts.length > 1)
101
+ return andParts.every((part) => isAllowed(part, allowlist));
102
+ return allowlist.has(stripped);
103
+ }
104
+ function licenseStringOf(meta) {
105
+ const license = meta.license;
106
+ if (typeof license === 'string')
107
+ return license;
108
+ if (license &&
109
+ typeof license === 'object' &&
110
+ typeof license.type === 'string')
111
+ return license.type;
112
+ return null;
113
+ }
114
+ // One scoped exception, applied to one entry. `prefix` names a family of
115
+ // packages and `license` is the single identifier the exception adds to the
116
+ // allowlist for that family; the expression is then read by the rule every other
117
+ // entry is read by. So an AND still needs every operand covered and a WITH
118
+ // compound still has to sit in the list exactly: `Apache-2.0 AND
119
+ // LGPL-3.0-or-later` is tolerated where the exception names the LGPL term and
120
+ // the allowlist carries Apache-2.0, and `LGPL-3.0-or-later AND AGPL-3.0-only` is
121
+ // not. A substring test here kept tolerating a family the day its expression
122
+ // widened to carry a term nobody agreed to. The caller has already decided this
123
+ // exception applies to this lockfile and that its marker still holds.
124
+ function isTolerated(tolerance, meta, name, license, allowlist) {
125
+ if (!name.startsWith(tolerance.prefix))
126
+ return false;
127
+ if (tolerance.optional !== false && meta.optional !== true)
128
+ return false;
129
+ return isAllowed(license, new Set([...allowlist, tolerance.license]));
130
+ }
131
+ // Resolves how npm's hoisting algorithm would look up dependency `name` starting from the package
132
+ // at `fromPath`: its own node_modules first, then each ancestor's, ending at the root.
133
+ //
134
+ // Must split on "/node_modules/" boundaries, not bare "/": a scoped package's path
135
+ // (node_modules/@scope/name) has three segments per nesting level, not two, so striding by two
136
+ // undershoots every scoped ancestor and never reaches the root scope "". That silently drops the
137
+ // dependency edge for anything nested under a scoped package - including every @biomejs/cli-*
138
+ // platform binary - and findDependencyPath falls back to the raw lockfile key instead of the real
139
+ // require-chain.
140
+ function ancestorScopesOf(pkgPath) {
141
+ if (pkgPath === '')
142
+ return [''];
143
+ const scopes = [];
144
+ let scope = pkgPath;
145
+ for (;;) {
146
+ scopes.push(scope);
147
+ const boundary = scope.lastIndexOf('/node_modules/');
148
+ if (boundary === -1)
149
+ break;
150
+ scope = scope.slice(0, boundary);
151
+ }
152
+ scopes.push('');
153
+ return scopes;
154
+ }
155
+ function resolveDependency(packages, fromPath, name) {
156
+ for (const scope of ancestorScopesOf(fromPath)) {
157
+ const candidate = scope
158
+ ? `${scope}/node_modules/${name}`
159
+ : `node_modules/${name}`;
160
+ if (packages[candidate])
161
+ return candidate;
162
+ }
163
+ return null;
164
+ }
165
+ function buildEdges(packages) {
166
+ const edges = new Map();
167
+ for (const [path, meta] of Object.entries(packages)) {
168
+ const wantedNames = new Set([
169
+ ...Object.keys(meta.dependencies ?? {}),
170
+ ...Object.keys(meta.optionalDependencies ?? {}),
171
+ ...Object.keys(meta.peerDependencies ?? {}),
172
+ ...(path === '' ? Object.keys(meta.devDependencies ?? {}) : []),
173
+ ]);
174
+ const list = [];
175
+ for (const name of wantedNames) {
176
+ const resolved = resolveDependency(packages, path, name);
177
+ if (resolved)
178
+ list.push({ name, childPath: resolved });
179
+ }
180
+ edges.set(path, list);
181
+ }
182
+ return edges;
183
+ }
184
+ // Breadth-first search from the root over the lockfile's dependency edges (walking edges, not
185
+ // `npm ls`, since this script needs no install) to find one shortest chain of require-names that
186
+ // reaches `targetPath`.
187
+ function findDependencyPath(packages, edges, targetPath) {
188
+ const rootName = packages['']?.name ?? '(root)';
189
+ if (targetPath === '')
190
+ return rootName;
191
+ const visited = new Set(['']);
192
+ const queue = [''];
193
+ const parent = new Map(); // childPath -> { parentPath, name }
194
+ while (queue.length > 0) {
195
+ const current = queue.shift();
196
+ for (const { name, childPath } of edges.get(current) ?? []) {
197
+ if (visited.has(childPath))
198
+ continue;
199
+ visited.add(childPath);
200
+ parent.set(childPath, { parentPath: current, name });
201
+ if (childPath === targetPath) {
202
+ const chain = [];
203
+ let cursor = targetPath;
204
+ while (cursor !== '') {
205
+ const step = parent.get(cursor);
206
+ chain.unshift(step.name);
207
+ cursor = step.parentPath;
208
+ }
209
+ return [rootName, ...chain].join(' > ');
210
+ }
211
+ queue.push(childPath);
212
+ }
213
+ }
214
+ return targetPath; // unreachable via declared edges; fall back to the raw lockfile key
215
+ }
216
+ /**
217
+ * `options.allowlist` is required and has no default. An absent allowlist either
218
+ * fails every entry or silently permits every entry, and a gate that picks one
219
+ * of those on the caller's behalf is the fallback this package does not have.
220
+ *
221
+ * `options.undeclared` is the rows for entries whose manifest declares no
222
+ * licence, each a `prefix`, the one identifier the entry is read as under
223
+ * `readAs`, the `evidence` for that reading and a `reason`. The caller has
224
+ * already decided which rows apply to this lockfile. A row that reached no
225
+ * undeclared entry comes back by prefix in `unusedUndeclared`, so the caller
226
+ * can refuse a reading nothing is holding.
227
+ *
228
+ * `options.source` is the path this lockfile was read from, named in the refusal
229
+ * a document without a `packages` object earns.
230
+ */
231
+ export function checkLicenses(lockfile, options = {}) {
232
+ const allowed = options.allowlist;
233
+ if (!Array.isArray(allowed) || allowed.length === 0) {
234
+ throw new Error('check-licenses: no allowlist was supplied; the licences gate carries none of its own');
235
+ }
236
+ const allowlist = new Set(allowed);
237
+ const label = typeof options.label === 'string' ? options.label : 'allowlist';
238
+ const tolerances = options.tolerances ?? [];
239
+ const undeclared = options.undeclared ?? [];
240
+ const source = typeof options.source === 'string' ? options.source : 'the lockfile';
241
+ // Every entry is read from `packages`, which npm writes from lockfileVersion 2
242
+ // onward. Defaulting it to {} turned an npm 6 lockfile, or a path naming
243
+ // something that is not a lockfile at all, into a run that reported success
244
+ // over zero entries: a gate that scanned nothing, in the words of a gate that
245
+ // passed.
246
+ const packages = lockfile?.packages;
247
+ if (packages === null ||
248
+ typeof packages !== 'object' ||
249
+ Array.isArray(packages)) {
250
+ throw refuseLockfileShape(`check-licenses: ${source} carries no "packages" object (lockfileVersion ${JSON.stringify(lockfile?.lockfileVersion ?? null)}); every entry is read from there, so this run would have passed over nothing. npm writes "packages" from lockfileVersion 2 onward.`);
251
+ }
252
+ // A `link: true` entry is a workspace symlink, not an installed artifact with its own licence -
253
+ // audit-lockfile-age.mjs already excludes these; this script should agree instead of flagging a
254
+ // symlink for a `license` field it was never going to have.
255
+ const entries = Object.entries(packages).filter(([pkgPath, meta]) => pkgPath !== '' && !meta.link);
256
+ const violations = [];
257
+ const tolerated = [];
258
+ const readByEvidence = [];
259
+ const usedPrefixes = new Set();
260
+ const reasons = new Set();
261
+ for (const [pkgPath, meta] of entries) {
262
+ const name = meta.name ?? pkgPath.split('node_modules/').pop();
263
+ const version = meta.version ?? '(unknown)';
264
+ // `resolved` is the URL `npm ci` fetches, and `integrity` is checked against
265
+ // whatever that URL returns, so an entry can declare one package and install
266
+ // another. Pinning the host alone left that open: an entry naming
267
+ // `lodash@4.17.21` and resolving to `attacker-pkg-9.9.9.tgz` on
268
+ // registry.npmjs.org passed, and the licence being read was never the licence
269
+ // of the artifact being installed. Requiring `resolved` to be the one tarball
270
+ // URL the registry has for this entry's own name and version is what ties the
271
+ // two together.
272
+ // "Declares nothing" is an absent, null or blank field. A field present in
273
+ // a shape `licenseStringOf` does not read, an array or an object with no
274
+ // `type`, declares something and fails below as it always has, rather than
275
+ // reading as nothing and taking a row's evidence.
276
+ const declaresNothing = meta.license === undefined ||
277
+ meta.license === null ||
278
+ (typeof meta.license === 'string' && meta.license.trim() === '');
279
+ const matching = declaresNothing
280
+ ? undeclared.filter((candidate) => name.startsWith(candidate.prefix))
281
+ : [];
282
+ // A row is held to have reached an entry before the resolved-URL check
283
+ // below, so a tampered entry the row documents reports the tampering and
284
+ // never a row that reaches nothing.
285
+ for (const row of matching)
286
+ usedPrefixes.add(row.prefix);
287
+ const expected = registryTarballUrl(name, version);
288
+ if (meta.resolved !== expected) {
289
+ violations.push({
290
+ path: pkgPath,
291
+ name,
292
+ version,
293
+ license: meta.license ?? null,
294
+ reason: `resolved=${JSON.stringify(meta.resolved ?? null)} is not ${name}@${version}'s registry tarball ${expected}`,
295
+ });
296
+ continue;
297
+ }
298
+ const license = licenseStringOf(meta);
299
+ // A manifest with no licence field declares nothing, so there is no
300
+ // expression to widen and no tolerance is consulted. A row under
301
+ // `undeclared` supplies the reading and its evidence, and the identifier is
302
+ // then held by `isAllowed` like a declared one, so a row cannot admit what
303
+ // the allowlist refuses.
304
+ if (declaresNothing) {
305
+ const blank = typeof meta.license === 'string';
306
+ const undeclaredAs = blank
307
+ ? 'declares no licence, the field is blank'
308
+ : 'declares no licence';
309
+ if (matching.length === 0) {
310
+ violations.push({
311
+ path: pkgPath,
312
+ name,
313
+ version,
314
+ license: meta.license ?? null,
315
+ reason: undeclaredAs,
316
+ });
317
+ continue;
318
+ }
319
+ // The first row whose reading the allowlist admits, as a tolerance is
320
+ // the first that holds; the failure names every reading that was tried.
321
+ const row = matching.find((candidate) => isAllowed(candidate.readAs, allowlist));
322
+ if (row === undefined) {
323
+ const tried = [...new Set(matching.map((c) => c.readAs))].join(' or ');
324
+ violations.push({
325
+ path: pkgPath,
326
+ name,
327
+ version,
328
+ license: meta.license ?? null,
329
+ reason: `read by evidence as ${tried}, which is outside ${label}`,
330
+ });
331
+ continue;
332
+ }
333
+ readByEvidence.push({
334
+ entry: `${name}@${version}`,
335
+ readAs: row.readAs,
336
+ evidence: row.evidence,
337
+ reason: row.reason,
338
+ });
339
+ continue;
340
+ }
341
+ if (isAllowed(license, allowlist))
342
+ continue;
343
+ const tolerance = tolerances.find((candidate) => isTolerated(candidate, meta, name, license, allowlist));
344
+ if (tolerance !== undefined) {
345
+ tolerated.push(`${name}@${version}`);
346
+ reasons.add(tolerance.reason);
347
+ continue;
348
+ }
349
+ // The raw field when the reader made nothing of it, so `["MIT"]` prints as
350
+ // what it is and never as the null that means "declares nothing".
351
+ violations.push({
352
+ path: pkgPath,
353
+ name,
354
+ version,
355
+ license: license ?? meta.license,
356
+ });
357
+ }
358
+ tolerated.sort();
359
+ readByEvidence.sort((a, b) => a.entry < b.entry ? -1 : a.entry > b.entry ? 1 : 0);
360
+ const report = {
361
+ violations,
362
+ entryCount: entries.length,
363
+ tolerated,
364
+ toleranceReasons: [...reasons].sort(),
365
+ readByEvidence,
366
+ unusedUndeclared: undeclared
367
+ .map((row) => row.prefix)
368
+ .filter((prefix) => !usedPrefixes.has(prefix)),
369
+ policy: label,
370
+ };
371
+ if (violations.length === 0)
372
+ return report;
373
+ const edges = buildEdges(packages);
374
+ for (const violation of violations) {
375
+ violation.dependencyPath = findDependencyPath(packages, edges, violation.path);
376
+ }
377
+ return report;
378
+ }
@@ -0,0 +1,104 @@
1
+ // The bound on a regular expression a consumer writes, shared by every gate
2
+ // that takes one.
3
+ //
4
+ // Four gates now accept a pattern out of `eval-quality.config.json`, and the
5
+ // bound has to be the same in all four: a length cap on the source, a flag set
6
+ // that excludes the two flags carrying a match position between calls, a
7
+ // refusal of backreferences, and a compile check so a malformed pattern is a
8
+ // configuration error rather than a crash at the first line it reads.
9
+ //
10
+ // `package-boundary.ts` states why these are the bounds and what they leave
11
+ // uncovered. That note stays there, beside `MAX_SCANNED_LINE`, because the
12
+ // input bound is the scanner's own and only the pattern half is shared.
13
+ //
14
+ // Run by `node` directly: Node's type stripping erases types only, so no
15
+ // TypeScript enum, namespace, parameter property, or non-type re-export may
16
+ // appear in this file or anything it imports.
17
+ import { z } from 'zod';
18
+ export const MAX_PATTERN_LENGTH = 200;
19
+ /**
20
+ * The same bound for a pattern that describes a sentence.
21
+ *
22
+ * A boundary pattern names a construct, and 200 characters is more than any of
23
+ * them needs. A documentation pattern quotes prose: it carries the words either
24
+ * side of the number or the list it captures, because those words are what stop
25
+ * it matching a different sentence on the same page. Holding it to the shorter
26
+ * bound would push consumers towards loose patterns, which is the failure mode
27
+ * these gates exist to close.
28
+ *
29
+ * Length is the weaker half of the bound in both cases. What the work actually
30
+ * rests on is the backreference refusal and the size of the subject, and both
31
+ * are unchanged here.
32
+ */
33
+ export const MAX_PROSE_PATTERN_LENGTH = 800;
34
+ /**
35
+ * `\1` through `\9` and `\k<name>`. It over-refuses an escaped backslash
36
+ * followed by a digit, which is a literal backslash and not a backreference,
37
+ * and that spelling has no place in a configured pattern anyway.
38
+ */
39
+ const BACKREFERENCE = /\\[1-9]|\\k</;
40
+ const FLAG_MESSAGE = 'admits only i, m, s, u and v. A g or a y carries a match position between calls, so a pattern holding either would match every second thing it should have matched';
41
+ export const PatternSource = z.string().min(1).max(MAX_PATTERN_LENGTH);
42
+ export const ProsePatternSource = z
43
+ .string()
44
+ .min(1)
45
+ .max(MAX_PROSE_PATTERN_LENGTH);
46
+ export const PatternFlags = z
47
+ .string()
48
+ .regex(/^[imsuv]*$/, FLAG_MESSAGE)
49
+ .default('');
50
+ /**
51
+ * The two refusals a length bound and a flag set do not cover. Exported as a
52
+ * function so a gate composing its own object around a pattern reports them
53
+ * against its own key path.
54
+ */
55
+ export function refinePattern(match, flags, ctx, path) {
56
+ if (BACKREFERENCE.test(match)) {
57
+ ctx.addIssue({
58
+ code: 'custom',
59
+ path,
60
+ message: 'carries a backreference, which is the construct that turns a linear scan into an exponential one; write the pattern without one',
61
+ });
62
+ }
63
+ try {
64
+ new RegExp(match, flags);
65
+ }
66
+ catch (error) {
67
+ ctx.addIssue({
68
+ code: 'custom',
69
+ path,
70
+ message: `is not a regular expression: ${error instanceof Error ? error.message : String(error)}`,
71
+ });
72
+ }
73
+ }
74
+ /**
75
+ * A pattern on its own, for a gate with nothing to say about it beyond where it
76
+ * is matched. A gate that reports under a name or carries a reason composes
77
+ * `PatternSource`, `PatternFlags` and `refinePattern` into its own object
78
+ * instead.
79
+ */
80
+ export const ConsumerPattern = z
81
+ .strictObject({
82
+ match: PatternSource.describe('The regular expression, as source text.'),
83
+ flags: PatternFlags.describe('Regular-expression flags. Empty by default.'),
84
+ })
85
+ .superRefine((pattern, ctx) => {
86
+ refinePattern(pattern.match, pattern.flags, ctx, ['match']);
87
+ });
88
+ /** The same object at the prose bound, for the documentation gates. */
89
+ export const ProsePattern = z
90
+ .strictObject({
91
+ match: ProsePatternSource.describe('The regular expression, as source text.'),
92
+ flags: PatternFlags.describe('Regular-expression flags. Empty by default.'),
93
+ })
94
+ .superRefine((pattern, ctx) => {
95
+ refinePattern(pattern.match, pattern.flags, ctx, ['match']);
96
+ });
97
+ export const compilePattern = (pattern) => new RegExp(pattern.match, pattern.flags);
98
+ /**
99
+ * The same pattern with `g` added, for a gate that counts every occurrence.
100
+ * `g` is outside the configured flag set because a consumer cannot be given a
101
+ * stateful `lastIndex`; a gate that needs it adds it at the point of use, where
102
+ * the regular expression is built fresh for each subject.
103
+ */
104
+ export const compileGlobalPattern = (pattern) => new RegExp(pattern.match, `${pattern.flags}g`);