@ecoma-io/archkeep 0.13.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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- package/src/workspace.mjs +580 -0
|
@@ -0,0 +1,1708 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nx's own matchers, ported literally — the three places where "it looked like
|
|
3
|
+
* a glob" is wrong.
|
|
4
|
+
*
|
|
5
|
+
* `@nx/enforce-module-boundaries` matches patterns in three different dialects,
|
|
6
|
+
* none of them minimatch, and each is a place a reimplementation silently
|
|
7
|
+
* stops agreeing with ESLint:
|
|
8
|
+
*
|
|
9
|
+
* 1. `allow` and `checkDynamicDependenciesExceptions` use
|
|
10
|
+
* `matchImportWithWildcard`, which understands exactly three shapes — a
|
|
11
|
+
* trailing `…/**`, a trailing `…/*`, and a double-star segment between a
|
|
12
|
+
* prefix and a suffix — and otherwise falls through to
|
|
13
|
+
* `new RegExp(pattern)` — UNANCHORED. `allow: ["@scope/pkg"]` is a regular
|
|
14
|
+
* expression, so it also matches `@scope/pkg-internal` and `x@scopeYpkg`.
|
|
15
|
+
* 2. `bannedExternalImports` / `allowedExternalImports` and glob-shaped tags use
|
|
16
|
+
* `mapGlobToRegExp`, which turns every run of `*` into `.*` and anchors the
|
|
17
|
+
* result. Every other regex metacharacter survives — `.` still means "any
|
|
18
|
+
* character", so `@tauri-apps/api` also matches `@tauri-appsXapi`.
|
|
19
|
+
* 3. `ignoredCircularDependencies` entries go through Nx's
|
|
20
|
+
* `findMatchingProjects`, whose unlabeled patterns are neither names nor
|
|
21
|
+
* globs but a case-insensitive word-boundary regex over project names.
|
|
22
|
+
*
|
|
23
|
+
* Swapping any of these for a glob library keeps the tests green on the simple
|
|
24
|
+
* cases and quietly changes which imports escape, which is why they are ported
|
|
25
|
+
* here rather than approximated. The `…Error` helpers exist so `../config.mjs`
|
|
26
|
+
* can reject a pattern that will not compile at load, naming it, instead of
|
|
27
|
+
* throwing from inside a rule halfway through a run — and, since a pattern
|
|
28
|
+
* that compiles can still cost more than the whole run is worth,
|
|
29
|
+
* `regexComplexityError` rejects the shapes that backtrack catastrophically at
|
|
30
|
+
* that same door. All three dialects above compile a pattern the consumer
|
|
31
|
+
* wrote, so all three reach it.
|
|
32
|
+
*
|
|
33
|
+
* What that guard bounds is the pattern. The cost is a product of two things,
|
|
34
|
+
* and the other one — how long the SUBJECT is — is bounded by
|
|
35
|
+
* `MAX_SPECIFIER_LENGTH` at the doors a specifier arrives at, here and in
|
|
36
|
+
* `./specifiers.mjs`. Neither bound is sufficient alone.
|
|
37
|
+
*
|
|
38
|
+
* A fourth dialect lives at the bottom of this file, unrelated to Nx:
|
|
39
|
+
* `path.posix.matchesGlob`, the Node built-in `boundarySuppressions[].path`,
|
|
40
|
+
* `coverage.exempt[].path`, `projectRules[].match` and
|
|
41
|
+
* `projects.infer.include`/`exclude` are matched with. `safeMatchesGlob` and
|
|
42
|
+
* its `globComplexityError` guard live here for the same reason the three
|
|
43
|
+
* dialects above do: one shared implementation every caller reaches through,
|
|
44
|
+
* rather than each one reimplementing the check.
|
|
45
|
+
*/
|
|
46
|
+
import { posix } from "node:path";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The most repetitions one pattern may be forced to RE-SPLIT before
|
|
50
|
+
* `regexComplexityError` refuses it — chosen from measurement, the way
|
|
51
|
+
* `MAX_GLOB_EXPANSIONS` further down was.
|
|
52
|
+
*
|
|
53
|
+
* **How many `*`s a pattern carries is not what decides its cost.** What
|
|
54
|
+
* decides it is whether a required atom that CAN FAIL follows them: a failing
|
|
55
|
+
* atom is what makes a backtracking engine try every division of the subject
|
|
56
|
+
* across the wildcards before it gives up, and an anchor at the end of a `.*`
|
|
57
|
+
* cannot fail, so it never starts. Measured on Node v24.16.0, one
|
|
58
|
+
* `re.test(subject)` on a subject the pattern never matches, by subject
|
|
59
|
+
* length:
|
|
60
|
+
*
|
|
61
|
+
* | pattern | subject | 500 | 1000 | 2000 |
|
|
62
|
+
* | ----------------------- | ----------------- | ----- | ----- | ------ |
|
|
63
|
+
* | `^a.*a.*a.*z$` | `"a"*n` | 27ms | 190ms | 1505ms |
|
|
64
|
+
* | `^.*-.*-.*x$` | `"-"*n` | 25ms | 194ms | 1483ms |
|
|
65
|
+
* | `^@scope\/.*\/.*\/.*x$` | `"@scope/"+"/"*n` | 26ms | 193ms | 1499ms |
|
|
66
|
+
* | `^.*-.*-.*$` | `"-"*n` | 0.0ms | 0.0ms | 0.0ms |
|
|
67
|
+
* | `^@scope\/.*\/.*\/.*$` | `"@scope/"+"/"*n` | 0.0ms | 0.0ms | 0.0ms |
|
|
68
|
+
*
|
|
69
|
+
* The bottom two rows carry three unbounded repetitions each and an
|
|
70
|
+
* adversarial subject, and cost nothing at all: their last `.*` runs to the
|
|
71
|
+
* end of the subject where `$` succeeds, so no division is ever retried. The
|
|
72
|
+
* top three differ from them by one character. That difference is what this
|
|
73
|
+
* guard counts, because it is the difference that shows up in the clock — and
|
|
74
|
+
* it is why the cap can be as low as two without refusing the shapes a real
|
|
75
|
+
* policy is written in. `mapGlobToRegExp` ANCHORS everything it builds, so
|
|
76
|
+
* a glob naming three wildcard segments under a scope compiles to
|
|
77
|
+
* `^@acme\/.*\/.*\/.*$` — the bottom two rows, provably — while the same glob
|
|
78
|
+
* with one more literal segment after it compiles to a failing tail and is
|
|
79
|
+
* genuinely the top three.
|
|
80
|
+
*
|
|
81
|
+
* Two is the cap because three is where the clock moves: with a failing tail,
|
|
82
|
+
* `a+a+a+$` costs 23ms against a 140-character subject where `a+a+$` costs
|
|
83
|
+
* 0.7ms, and `^a.*a.*a.*z$` costs 1.5 SECONDS against a 2000-character one
|
|
84
|
+
* where `^a.*a.*z$` costs 2.3ms. What the polynomial that survives the cap
|
|
85
|
+
* costs is bounded at the other end, by `MAX_SPECIFIER_LENGTH` below, because
|
|
86
|
+
* a degree-2 pattern is still quadratic in a subject nothing else limits.
|
|
87
|
+
*/
|
|
88
|
+
export const MAX_RESPLIT_REPETITIONS = 2;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The most repetitions a SINGLE-DELIMITER chain may carry — the one shape
|
|
92
|
+
* that is allowed past `MAX_RESPLIT_REPETITIONS`, because for it the
|
|
93
|
+
* exhaustive re-split cannot happen.
|
|
94
|
+
*
|
|
95
|
+
* `^.*\/.*\/.*\/.*$` — what a glob of four wildcard segments compiles to —
|
|
96
|
+
* has three repetitions in front of a required atom, and is free on every
|
|
97
|
+
* subject:
|
|
98
|
+
* every atom it requires after the first wildcard is the SAME character, so a
|
|
99
|
+
* subject with enough of them MATCHES — greedily, on the first or second
|
|
100
|
+
* attempt — and a subject without enough of them has fewer than three places
|
|
101
|
+
* to try. Abundance and failure cannot happen at once, which is exactly what
|
|
102
|
+
* `^a.*a.*a.*z$` arranges by asking for a `z` that never comes.
|
|
103
|
+
*
|
|
104
|
+
* It is a cap and not a licence because the small search that remains still
|
|
105
|
+
* grows with the number of segments. Measured on Node v24.16.0 against a
|
|
106
|
+
* 2000-character subject holding one delimiter fewer than the pattern needs —
|
|
107
|
+
* the worst case, a guaranteed failure with the most positions to try:
|
|
108
|
+
*
|
|
109
|
+
* | segments | 6 | 8 | 12 | 16 | 18 | 20 |
|
|
110
|
+
* | -------- | ----- | ----- | ----- | ----- | ------ | ------ |
|
|
111
|
+
* | cost | 0.0ms | 0.0ms | 0.3ms | 3.6ms | 11.8ms | 69.1ms |
|
|
112
|
+
*
|
|
113
|
+
* Eight is far below where that curve turns and far above any real glob: a
|
|
114
|
+
* path pattern naming every segment of `libs/<area>/<name>/<entry>` needs
|
|
115
|
+
* four.
|
|
116
|
+
*/
|
|
117
|
+
export const MAX_DELIMITED_SEGMENTS = 8;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The longest import specifier this engine will match a consumer-written
|
|
121
|
+
* pattern against. Past it the specifier is not judged, and not judging is
|
|
122
|
+
* reported rather than passed — `assertMatchableSpecifier` at the foot of
|
|
123
|
+
* this section is where that happens and carries the argument.
|
|
124
|
+
*
|
|
125
|
+
* The two refusals above bound the SHAPE of a pattern; this bounds the other
|
|
126
|
+
* multiplicand. A pattern that survives them is still quadratic in the length
|
|
127
|
+
* of a subject that does not match, and nothing else in this pipeline limits
|
|
128
|
+
* that length: a specifier is text read out of a source file, and a source
|
|
129
|
+
* file is attacker-supplied (`../../../../SECURITY.md`). Measured on Node
|
|
130
|
+
* v24.16.0, a chain at exactly the cap (`^a.*a.*z$` against `"a"*n`, and
|
|
131
|
+
* `^@s\/.*\/.*x$` against `"@s/"+"/"*n`) by subject length:
|
|
132
|
+
*
|
|
133
|
+
* | length | 256 | 512 | 1024 | 2048 | 4096 |
|
|
134
|
+
* | --------------- | ------ | ------ | ------ | ------ | ------- |
|
|
135
|
+
* | `^a.*a.*z$` | 0.04ms | 0.16ms | 0.58ms | 2.28ms | 9.16ms |
|
|
136
|
+
* | `^@s\/.*\/.*x$` | 0.05ms | 0.20ms | 0.80ms | 3.18ms | 13.45ms |
|
|
137
|
+
*
|
|
138
|
+
* 1024 is the last length at which that chain stays under a millisecond per
|
|
139
|
+
* site, and it is an order of magnitude past anything a specifier can actually
|
|
140
|
+
* be: the longest thing the four analyzers emit is a path or a dotted module
|
|
141
|
+
* name, and `../analysis/` has never produced one over 200 characters. A 400KB
|
|
142
|
+
* "specifier" is not a specifier.
|
|
143
|
+
*
|
|
144
|
+
* The two named chains are not the ceiling, only the calibration. Three
|
|
145
|
+
* thousand randomly generated patterns were run against seventeen adversarial
|
|
146
|
+
* subjects of exactly this length, and the worst SINGLE call any pattern the
|
|
147
|
+
* guards accept managed was 7.4ms — `((a|[a-z]?)b{2,})?x+(?:\d){2,}` against
|
|
148
|
+
* 1024 `b`s. That is the number to compare a change here against; before this
|
|
149
|
+
* bound and the refusals above, the same sweep found 59 SECONDS.
|
|
150
|
+
*/
|
|
151
|
+
export const MAX_SPECIFIER_LENGTH = 1024;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The deepest group nesting this model reads. Past it a pattern is refused
|
|
155
|
+
* rather than modelled — the walk below is recursive over group structure, and
|
|
156
|
+
* a pattern is a config value a pull request can write, so a thousand nested
|
|
157
|
+
* `(` would trade the denial of service this file exists to prevent for a
|
|
158
|
+
* stack overflow instead (the reason `braceExpansionCount` further down uses
|
|
159
|
+
* an explicit stack). No regular expression a boundary policy is written in
|
|
160
|
+
* nests past three.
|
|
161
|
+
*/
|
|
162
|
+
const MAX_GROUP_DEPTH = 32;
|
|
163
|
+
|
|
164
|
+
/* ------------------------------------------------------------------------ *
|
|
165
|
+
* A regular expression, read as a shape
|
|
166
|
+
* ------------------------------------------------------------------------ */
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The characters one atom can match: explicit code-point ranges, or `any`.
|
|
170
|
+
*
|
|
171
|
+
* `any` is the answer for everything this model does not resolve exactly —
|
|
172
|
+
* `.`, a negated class, `\D`/`\W`/`\S`, a `\p{…}` property, a backreference.
|
|
173
|
+
* Every question asked of a `CharSet` below is "can these two match the same
|
|
174
|
+
* character?", and answering yes can only ever make this file REFUSE a
|
|
175
|
+
* pattern that would have been cheap. It can never let an expensive one
|
|
176
|
+
* through, which is the direction that matters.
|
|
177
|
+
*
|
|
178
|
+
* @typedef {{ any: boolean, ranges: [number, number][] }} CharSet
|
|
179
|
+
*/
|
|
180
|
+
|
|
181
|
+
/** @type {CharSet} */
|
|
182
|
+
const ANY_CHARS = { any: true, ranges: [] };
|
|
183
|
+
/** @type {CharSet} */
|
|
184
|
+
const NO_CHARS = { any: false, ranges: [] };
|
|
185
|
+
/** @type {CharSet} */
|
|
186
|
+
const DIGIT_CHARS = { any: false, ranges: [[48, 57]] };
|
|
187
|
+
/** @type {CharSet} */
|
|
188
|
+
const WORD_CHARS = {
|
|
189
|
+
any: false,
|
|
190
|
+
ranges: [
|
|
191
|
+
[48, 57],
|
|
192
|
+
[65, 90],
|
|
193
|
+
[95, 95],
|
|
194
|
+
[97, 122],
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* `\s` as ECMA-262 defines it, the Unicode entries included: a tag or a
|
|
199
|
+
* specifier is not restricted to ASCII.
|
|
200
|
+
*
|
|
201
|
+
* @type {CharSet}
|
|
202
|
+
*/
|
|
203
|
+
const SPACE_CHARS = {
|
|
204
|
+
any: false,
|
|
205
|
+
ranges: [
|
|
206
|
+
[9, 13],
|
|
207
|
+
[32, 32],
|
|
208
|
+
[160, 160],
|
|
209
|
+
[5760, 5760],
|
|
210
|
+
[8192, 8202],
|
|
211
|
+
[8232, 8233],
|
|
212
|
+
[8239, 8239],
|
|
213
|
+
[8287, 8287],
|
|
214
|
+
[12288, 12288],
|
|
215
|
+
[65279, 65279],
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {number} code
|
|
221
|
+
* @returns {CharSet}
|
|
222
|
+
*/
|
|
223
|
+
const oneChar = (code) => ({ any: false, ranges: [[code, code]] });
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* @param {CharSet[]} sets
|
|
227
|
+
* @returns {CharSet}
|
|
228
|
+
*/
|
|
229
|
+
function unionChars(sets) {
|
|
230
|
+
/** @type {[number, number][]} */
|
|
231
|
+
const ranges = [];
|
|
232
|
+
for (const set of sets) {
|
|
233
|
+
if (set.any) return ANY_CHARS;
|
|
234
|
+
ranges.push(...set.ranges);
|
|
235
|
+
}
|
|
236
|
+
return { any: false, ranges };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Can these two atoms match the same character? The one question this whole
|
|
241
|
+
* model is built to answer, and the reason `any` resolves to `true`.
|
|
242
|
+
*
|
|
243
|
+
* @param {CharSet} a
|
|
244
|
+
* @param {CharSet} b
|
|
245
|
+
* @returns {boolean}
|
|
246
|
+
*/
|
|
247
|
+
function charsOverlap(a, b) {
|
|
248
|
+
if (a.any || b.any) return true;
|
|
249
|
+
for (const [lo, hi] of a.ranges) {
|
|
250
|
+
for (const [otherLo, otherHi] of b.ranges) {
|
|
251
|
+
if (lo <= otherHi && otherLo <= hi) return true;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The single code point this set is, or `null` when it is anything else —
|
|
259
|
+
* how the single-delimiter exemption recognises "every separator is the same
|
|
260
|
+
* character".
|
|
261
|
+
*
|
|
262
|
+
* @param {CharSet} set
|
|
263
|
+
* @returns {number|null}
|
|
264
|
+
*/
|
|
265
|
+
function loneChar(set) {
|
|
266
|
+
if (set.any || set.ranges.length !== 1) return null;
|
|
267
|
+
const [lo, hi] = set.ranges[0];
|
|
268
|
+
return lo === hi ? lo : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* One node of the shape a pattern is read as.
|
|
273
|
+
*
|
|
274
|
+
* `pinned` is the property Rule 1 turns on: a node is pinned when repeating it
|
|
275
|
+
* cannot produce two different ways to match the same text. `chars` is
|
|
276
|
+
* everything it can consume ANYWHERE inside it, `first` everything it can
|
|
277
|
+
* begin with; the two differ exactly where a leading delimiter does its work.
|
|
278
|
+
*
|
|
279
|
+
* @typedef {object} PatternNode
|
|
280
|
+
* @property {"atom"|"anchor"|"look"|"seq"|"alt"|"rep"} kind
|
|
281
|
+
* @property {CharSet} chars Every character reachable anywhere inside it.
|
|
282
|
+
* @property {CharSet} first Every character it can begin with.
|
|
283
|
+
* @property {number} minLen
|
|
284
|
+
* @property {number} maxLen `Infinity` when unbounded.
|
|
285
|
+
* @property {boolean} pinned Repeating it cannot re-split the same text.
|
|
286
|
+
* @property {boolean} opaque The model did not resolve the shape exactly.
|
|
287
|
+
* @property {boolean} elastic It can consume a varying amount of text itself.
|
|
288
|
+
* @property {boolean} repeats It runs its body more than once.
|
|
289
|
+
* @property {string|null} problem The first Rule-1 refusal found inside it.
|
|
290
|
+
* @property {"^"|"$"|"b"|null} anchor
|
|
291
|
+
* @property {number} start Offset into the pattern source.
|
|
292
|
+
* @property {number} end
|
|
293
|
+
* @property {PatternNode[]} children
|
|
294
|
+
*/
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @param {Partial<PatternNode> & {kind: PatternNode["kind"], start: number, end: number}} fields
|
|
298
|
+
* @returns {PatternNode}
|
|
299
|
+
*/
|
|
300
|
+
function node(fields) {
|
|
301
|
+
return {
|
|
302
|
+
chars: NO_CHARS,
|
|
303
|
+
first: NO_CHARS,
|
|
304
|
+
minLen: 0,
|
|
305
|
+
maxLen: 0,
|
|
306
|
+
pinned: false,
|
|
307
|
+
opaque: false,
|
|
308
|
+
elastic: false,
|
|
309
|
+
repeats: false,
|
|
310
|
+
problem: null,
|
|
311
|
+
anchor: null,
|
|
312
|
+
children: [],
|
|
313
|
+
...fields,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* @param {CharSet} chars
|
|
319
|
+
* @param {number} start
|
|
320
|
+
* @param {number} end
|
|
321
|
+
* @param {boolean} [opaque]
|
|
322
|
+
* @returns {PatternNode}
|
|
323
|
+
*/
|
|
324
|
+
const atomNode = (chars, start, end, opaque = false) =>
|
|
325
|
+
node({
|
|
326
|
+
kind: "atom",
|
|
327
|
+
chars,
|
|
328
|
+
first: chars,
|
|
329
|
+
minLen: 1,
|
|
330
|
+
maxLen: 1,
|
|
331
|
+
pinned: !opaque,
|
|
332
|
+
opaque,
|
|
333
|
+
start,
|
|
334
|
+
end,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* @param {"^"|"$"|"b"} anchor
|
|
339
|
+
* @param {number} start
|
|
340
|
+
* @param {number} end
|
|
341
|
+
* @returns {PatternNode}
|
|
342
|
+
*/
|
|
343
|
+
const anchorNode = (anchor, start, end) => node({ kind: "anchor", anchor, start, end });
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Whether repeating this node can produce two ways to match one string — the
|
|
347
|
+
* whole of Rule 1, as three sufficient tests. Each is cheap and each is
|
|
348
|
+
* one-directional: it either proves the node cannot re-split, or says nothing,
|
|
349
|
+
* and saying nothing refuses.
|
|
350
|
+
*
|
|
351
|
+
* 1. **Fixed width.** Every match is the same length, so a run of them divides
|
|
352
|
+
* exactly one way. `(a|b)+`, `(ab)+`, `(a{2})+`.
|
|
353
|
+
* 2. **A pinned leading atom.** The body opens with an atom of fixed width
|
|
354
|
+
* whose characters appear nowhere else in the body, so every iteration
|
|
355
|
+
* starts at a character the previous one could not have eaten.
|
|
356
|
+
* `(\.\w+)*` — `\.` and `\w` are disjoint, which is what makes that idiom
|
|
357
|
+
* safe and what makes `(a+)+` and `(\s*a)+` not.
|
|
358
|
+
* 3. **A deterministic alternation.** No branch matches the empty string, each
|
|
359
|
+
* branch is itself pinned, and no two branches can begin with the same
|
|
360
|
+
* character — so at most one branch is ever in play. `(ui|core)+` is
|
|
361
|
+
* pinned; `(a|aa)+` and `(?:a|b|ab)*` are not.
|
|
362
|
+
*
|
|
363
|
+
* A body that is pinned by NONE of the three is refused. That includes shapes
|
|
364
|
+
* that are in fact unambiguous — `([a-z]+-)+` is pinned by its TRAILING
|
|
365
|
+
* delimiter, which none of these three sees — and refusing them is the
|
|
366
|
+
* deliberate direction: the overlap this decides is a property of the whole
|
|
367
|
+
* language a group matches, deciding it exactly is not cheap, and an
|
|
368
|
+
* over-refused pattern fails a config load loudly where an under-refused one
|
|
369
|
+
* hangs a run.
|
|
370
|
+
*
|
|
371
|
+
* @param {PatternNode} candidate
|
|
372
|
+
* @returns {boolean}
|
|
373
|
+
*/
|
|
374
|
+
function isPinned(candidate) {
|
|
375
|
+
if (candidate.opaque) return false;
|
|
376
|
+
if (candidate.minLen >= 1 && candidate.minLen === candidate.maxLen) return true;
|
|
377
|
+
if (candidate.kind === "alt") {
|
|
378
|
+
const branches = candidate.children;
|
|
379
|
+
if (branches.some((branch) => branch.minLen === 0 || !branch.pinned)) return false;
|
|
380
|
+
for (let i = 0; i < branches.length; i++) {
|
|
381
|
+
for (let j = i + 1; j < branches.length; j++) {
|
|
382
|
+
if (charsOverlap(branches[i].first, branches[j].first)) return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
if (candidate.kind === "seq") {
|
|
388
|
+
const [head, ...rest] = candidate.children;
|
|
389
|
+
if (!head || head.minLen < 1 || head.minLen !== head.maxLen) return false;
|
|
390
|
+
return !charsOverlap(head.chars, unionChars(rest.map((item) => item.chars)));
|
|
391
|
+
}
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* @param {PatternNode[]} items
|
|
397
|
+
* @param {number} start
|
|
398
|
+
* @param {number} end
|
|
399
|
+
* @returns {PatternNode}
|
|
400
|
+
*/
|
|
401
|
+
function seqNode(items, start, end) {
|
|
402
|
+
let minLen = 0;
|
|
403
|
+
let maxLen = 0;
|
|
404
|
+
/** @type {CharSet[]} */
|
|
405
|
+
const firsts = [];
|
|
406
|
+
let firstSettled = false;
|
|
407
|
+
for (const item of items) {
|
|
408
|
+
minLen += item.minLen;
|
|
409
|
+
maxLen += item.maxLen;
|
|
410
|
+
if (!firstSettled) {
|
|
411
|
+
firsts.push(item.first);
|
|
412
|
+
if (item.minLen >= 1) firstSettled = true;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const built = node({
|
|
416
|
+
kind: "seq",
|
|
417
|
+
chars: unionChars(items.map((item) => item.chars)),
|
|
418
|
+
first: unionChars(firsts),
|
|
419
|
+
minLen,
|
|
420
|
+
maxLen,
|
|
421
|
+
opaque: items.some((item) => item.opaque),
|
|
422
|
+
problem: items.map((item) => item.problem).find(Boolean) ?? null,
|
|
423
|
+
children: items,
|
|
424
|
+
start,
|
|
425
|
+
end,
|
|
426
|
+
});
|
|
427
|
+
built.pinned = isPinned(built);
|
|
428
|
+
return built;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* @param {PatternNode[]} branches
|
|
433
|
+
* @param {number} start
|
|
434
|
+
* @param {number} end
|
|
435
|
+
* @returns {PatternNode}
|
|
436
|
+
*/
|
|
437
|
+
function altNode(branches, start, end) {
|
|
438
|
+
const built = node({
|
|
439
|
+
kind: "alt",
|
|
440
|
+
chars: unionChars(branches.map((branch) => branch.chars)),
|
|
441
|
+
first: unionChars(branches.map((branch) => branch.first)),
|
|
442
|
+
minLen: Math.min(...branches.map((branch) => branch.minLen)),
|
|
443
|
+
maxLen: Math.max(...branches.map((branch) => branch.maxLen)),
|
|
444
|
+
opaque: branches.some((branch) => branch.opaque),
|
|
445
|
+
problem: branches.map((branch) => branch.problem).find(Boolean) ?? null,
|
|
446
|
+
children: branches,
|
|
447
|
+
start,
|
|
448
|
+
end,
|
|
449
|
+
});
|
|
450
|
+
built.pinned = isPinned(built);
|
|
451
|
+
return built;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* A lookaround: zero width, and opaque on purpose. Nothing here models what a
|
|
456
|
+
* lookahead asserts, so it consumes no characters, pins nothing, and counts as
|
|
457
|
+
* a requirement that can fail — while its body is still walked, because
|
|
458
|
+
* `(?=(a+)+)` is the same exponential as `(a+)+`.
|
|
459
|
+
*
|
|
460
|
+
* @param {PatternNode} body
|
|
461
|
+
* @param {number} start
|
|
462
|
+
* @param {number} end
|
|
463
|
+
* @returns {PatternNode}
|
|
464
|
+
*/
|
|
465
|
+
const lookNode = (body, start, end) =>
|
|
466
|
+
node({ kind: "look", opaque: true, problem: body.problem, children: [body], start, end });
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* @param {PatternNode} item
|
|
470
|
+
* @param {number} min
|
|
471
|
+
* @param {number} max `Infinity` for `*`, `+` and `{n,}`.
|
|
472
|
+
* @param {string} text The source slice, for the refusal message.
|
|
473
|
+
* @param {number} start
|
|
474
|
+
* @param {number} end
|
|
475
|
+
* @returns {PatternNode}
|
|
476
|
+
*/
|
|
477
|
+
function repNode(item, min, max, text, start, end) {
|
|
478
|
+
const minLen = item.minLen * min;
|
|
479
|
+
const maxLen =
|
|
480
|
+
max === 0 || item.maxLen === 0 ? 0 : max === Infinity ? Infinity : item.maxLen * max;
|
|
481
|
+
const built = node({
|
|
482
|
+
kind: "rep",
|
|
483
|
+
chars: item.chars,
|
|
484
|
+
first: item.first,
|
|
485
|
+
minLen,
|
|
486
|
+
maxLen,
|
|
487
|
+
opaque: item.opaque,
|
|
488
|
+
// A repetition that can vary how much it consumes is one a failing atom
|
|
489
|
+
// can force to try every division. `?` and `{2,3}` can vary by one, which
|
|
490
|
+
// is a constant factor rather than a factor of the subject's length.
|
|
491
|
+
elastic: max === Infinity || max - min >= 2,
|
|
492
|
+
repeats: max >= 2,
|
|
493
|
+
problem: item.problem ?? (max >= 2 && !item.pinned ? nestedRepetitionReason(text) : null),
|
|
494
|
+
children: [item],
|
|
495
|
+
start,
|
|
496
|
+
end,
|
|
497
|
+
});
|
|
498
|
+
built.pinned = minLen >= 1 && minLen === maxLen;
|
|
499
|
+
return built;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** A `{n}` / `{n,}` / `{n,m}` quantifier, read where the scan already is. */
|
|
503
|
+
const BRACE_QUANTIFIER = /\{(\d+)(?:,(\d*))?\}/y;
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The quantifier at `index`, or `null` when what is there is an ordinary
|
|
507
|
+
* character.
|
|
508
|
+
*
|
|
509
|
+
* @param {string} source
|
|
510
|
+
* @param {number} index
|
|
511
|
+
* @returns {{length: number, min: number, max: number}|null}
|
|
512
|
+
*/
|
|
513
|
+
function quantifierAt(source, index) {
|
|
514
|
+
const ch = source[index];
|
|
515
|
+
if (ch === "*") return { length: 1, min: 0, max: Infinity };
|
|
516
|
+
if (ch === "+") return { length: 1, min: 1, max: Infinity };
|
|
517
|
+
if (ch === "?") return { length: 1, min: 0, max: 1 };
|
|
518
|
+
if (ch !== "{") return null;
|
|
519
|
+
BRACE_QUANTIFIER.lastIndex = index;
|
|
520
|
+
const match = BRACE_QUANTIFIER.exec(source);
|
|
521
|
+
if (match === null) return null; // a literal `{`, which JS regexes allow
|
|
522
|
+
const min = Number(match[1]);
|
|
523
|
+
const max = match[2] === undefined ? min : match[2] === "" ? Infinity : Number(match[2]);
|
|
524
|
+
return { length: match[0].length, min, max };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* How many characters after `(` belong to the group's own opener rather than
|
|
529
|
+
* to its body — `(?:`, `(?=`, `(?!`, `(?<=`, `(?<!` and `(?<name>`. Read so
|
|
530
|
+
* the `?` in `(?:` is never mistaken for the optional quantifier.
|
|
531
|
+
*
|
|
532
|
+
* @param {string} source
|
|
533
|
+
* @param {number} open Index of the `(`.
|
|
534
|
+
* @returns {number}
|
|
535
|
+
*/
|
|
536
|
+
function groupPrefixLength(source, open) {
|
|
537
|
+
if (source[open + 1] !== "?") return 0;
|
|
538
|
+
const third = source[open + 2];
|
|
539
|
+
if (third === ":" || third === "=" || third === "!") return 2;
|
|
540
|
+
if (third === "<") {
|
|
541
|
+
const fourth = source[open + 3];
|
|
542
|
+
if (fourth === "=" || fourth === "!") return 3;
|
|
543
|
+
const close = source.indexOf(">", open + 3);
|
|
544
|
+
return close === -1 ? 2 : close - open;
|
|
545
|
+
}
|
|
546
|
+
return 1;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Is the group opening at `open` a lookahead or lookbehind? */
|
|
550
|
+
function isLookaround(source, open) {
|
|
551
|
+
if (source[open + 1] !== "?") return false;
|
|
552
|
+
const third = source[open + 2];
|
|
553
|
+
if (third === "=" || third === "!") return true;
|
|
554
|
+
return third === "<" && (source[open + 3] === "=" || source[open + 3] === "!");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The classes an escape resolves to; anything not here is `any` or a literal. */
|
|
558
|
+
const ESCAPE_CLASSES = new Map([
|
|
559
|
+
["d", DIGIT_CHARS],
|
|
560
|
+
["w", WORD_CHARS],
|
|
561
|
+
["s", SPACE_CHARS],
|
|
562
|
+
]);
|
|
563
|
+
/** The control escapes that are one literal character. */
|
|
564
|
+
const ESCAPE_LITERALS = new Map([
|
|
565
|
+
["n", 10],
|
|
566
|
+
["t", 9],
|
|
567
|
+
["r", 13],
|
|
568
|
+
["f", 12],
|
|
569
|
+
["v", 11],
|
|
570
|
+
["0", 0],
|
|
571
|
+
]);
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The atom the escape at `index` denotes, and where the scan resumes.
|
|
575
|
+
*
|
|
576
|
+
* @param {string} source
|
|
577
|
+
* @param {number} index Offset of the `\`.
|
|
578
|
+
* @returns {{node: PatternNode, next: number, code: number|null}} `code` is the
|
|
579
|
+
* single code point it is, for a caller reading a `[a-z]` range endpoint.
|
|
580
|
+
*/
|
|
581
|
+
function readEscape(source, index) {
|
|
582
|
+
const ch = source[index + 1];
|
|
583
|
+
const end = index + 2;
|
|
584
|
+
if (ch === undefined) {
|
|
585
|
+
return { node: atomNode(oneChar(92), index, index + 1), next: index + 1, code: 92 };
|
|
586
|
+
}
|
|
587
|
+
const named = ESCAPE_CLASSES.get(ch);
|
|
588
|
+
if (named) return { node: atomNode(named, index, end), next: end, code: null };
|
|
589
|
+
if (ch === "D" || ch === "W" || ch === "S" || ch === "k") {
|
|
590
|
+
return { node: atomNode(ANY_CHARS, index, end, true), next: end, code: null };
|
|
591
|
+
}
|
|
592
|
+
if (ch === "b" || ch === "B") {
|
|
593
|
+
return { node: anchorNode("b", index, end), next: end, code: 8 };
|
|
594
|
+
}
|
|
595
|
+
if (ch === "p" || ch === "P") {
|
|
596
|
+
const close = source.indexOf("}", end);
|
|
597
|
+
const next = source[end] === "{" && close !== -1 ? close + 1 : end;
|
|
598
|
+
return { node: atomNode(ANY_CHARS, index, next, true), next, code: null };
|
|
599
|
+
}
|
|
600
|
+
if (ch >= "1" && ch <= "9") {
|
|
601
|
+
// A backreference matches whatever the group did — a length this model
|
|
602
|
+
// cannot know, so nothing containing one is ever pinned.
|
|
603
|
+
return { node: atomNode(ANY_CHARS, index, end, true), next: end, code: null };
|
|
604
|
+
}
|
|
605
|
+
const literal = ESCAPE_LITERALS.get(ch);
|
|
606
|
+
if (literal !== undefined) {
|
|
607
|
+
return { node: atomNode(oneChar(literal), index, end), next: end, code: literal };
|
|
608
|
+
}
|
|
609
|
+
if (ch === "x" || ch === "u") {
|
|
610
|
+
const hex = readHexEscape(source, index);
|
|
611
|
+
if (hex)
|
|
612
|
+
return { node: atomNode(oneChar(hex.code), index, hex.next), next: hex.next, code: hex.code };
|
|
613
|
+
return { node: atomNode(ANY_CHARS, index, end, true), next: end, code: null };
|
|
614
|
+
}
|
|
615
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
616
|
+
return { node: atomNode(oneChar(code), index, end), next: end, code };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* `\xNN`, `\uNNNN` and `\u{N…}`, or `null` when what follows is not one.
|
|
621
|
+
*
|
|
622
|
+
* @param {string} source
|
|
623
|
+
* @param {number} index Offset of the `\`.
|
|
624
|
+
* @returns {{code: number, next: number}|null}
|
|
625
|
+
*/
|
|
626
|
+
function readHexEscape(source, index) {
|
|
627
|
+
const kind = source[index + 1];
|
|
628
|
+
const digits = kind === "x" ? 2 : 4;
|
|
629
|
+
if (kind === "u" && source[index + 2] === "{") {
|
|
630
|
+
const close = source.indexOf("}", index + 3);
|
|
631
|
+
if (close === -1) return null;
|
|
632
|
+
const body = source.slice(index + 3, close);
|
|
633
|
+
if (!/^[0-9a-fA-F]+$/.test(body)) return null;
|
|
634
|
+
return { code: Number.parseInt(body, 16), next: close + 1 };
|
|
635
|
+
}
|
|
636
|
+
const body = source.slice(index + 2, index + 2 + digits);
|
|
637
|
+
if (body.length !== digits || !/^[0-9a-fA-F]+$/.test(body)) return null;
|
|
638
|
+
return { code: Number.parseInt(body, 16), next: index + 2 + digits };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* One member of a character class — a literal, an escape, or a class escape —
|
|
643
|
+
* and where the scan resumes.
|
|
644
|
+
*
|
|
645
|
+
* @param {string} source
|
|
646
|
+
* @param {number} index
|
|
647
|
+
* @returns {{chars: CharSet, code: number|null, next: number, opaque: boolean}}
|
|
648
|
+
*/
|
|
649
|
+
function readClassMember(source, index) {
|
|
650
|
+
if (source[index] === "\\") {
|
|
651
|
+
// `\b` inside a class is a backspace, not a word boundary — the one place
|
|
652
|
+
// an escape means something different in here than out there.
|
|
653
|
+
if (source[index + 1] === "b")
|
|
654
|
+
return { chars: oneChar(8), code: 8, next: index + 2, opaque: false };
|
|
655
|
+
const escape = readEscape(source, index);
|
|
656
|
+
return {
|
|
657
|
+
chars: escape.node.chars,
|
|
658
|
+
code: escape.node.opaque ? null : escape.code,
|
|
659
|
+
next: escape.next,
|
|
660
|
+
opaque: escape.node.opaque,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
const code = source.codePointAt(index) ?? 0;
|
|
664
|
+
return {
|
|
665
|
+
chars: oneChar(code),
|
|
666
|
+
code,
|
|
667
|
+
next: index + String.fromCodePoint(code).length,
|
|
668
|
+
opaque: false,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* The atom the character class starting at `index` denotes.
|
|
674
|
+
*
|
|
675
|
+
* A NEGATED class resolves to `any`: what `[^;]` matches is very nearly every
|
|
676
|
+
* character, and the one question asked of the answer is whether two atoms can
|
|
677
|
+
* overlap — where "very nearly everything" and "everything" give the same
|
|
678
|
+
* verdict for every pattern that is not contrived, and the contrived direction
|
|
679
|
+
* is a refusal.
|
|
680
|
+
*
|
|
681
|
+
* @param {string} source
|
|
682
|
+
* @param {number} index Offset of the `[`.
|
|
683
|
+
* @returns {{node: PatternNode, next: number}}
|
|
684
|
+
*/
|
|
685
|
+
function readClass(source, index) {
|
|
686
|
+
let i = index + 1;
|
|
687
|
+
let negated = false;
|
|
688
|
+
if (source[i] === "^") {
|
|
689
|
+
negated = true;
|
|
690
|
+
i++;
|
|
691
|
+
}
|
|
692
|
+
/** @type {[number, number][]} */
|
|
693
|
+
const ranges = [];
|
|
694
|
+
let any = false;
|
|
695
|
+
while (i < source.length && source[i] !== "]") {
|
|
696
|
+
const member = readClassMember(source, i);
|
|
697
|
+
i = member.next;
|
|
698
|
+
if (
|
|
699
|
+
member.code !== null &&
|
|
700
|
+
source[i] === "-" &&
|
|
701
|
+
i + 1 < source.length &&
|
|
702
|
+
source[i + 1] !== "]"
|
|
703
|
+
) {
|
|
704
|
+
const upper = readClassMember(source, i + 1);
|
|
705
|
+
i = upper.next;
|
|
706
|
+
if (upper.code !== null) {
|
|
707
|
+
ranges.push([member.code, upper.code]);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
any = any || upper.opaque || upper.chars.any;
|
|
711
|
+
ranges.push(...member.chars.ranges, ...upper.chars.ranges, [45, 45]);
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (member.chars.any || member.opaque) any = true;
|
|
715
|
+
else ranges.push(...member.chars.ranges);
|
|
716
|
+
}
|
|
717
|
+
const end = i < source.length ? i + 1 : i;
|
|
718
|
+
const chars = negated || any ? ANY_CHARS : { any: false, ranges };
|
|
719
|
+
return { node: atomNode(chars, index, end), next: end };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* `source` read as a shape — total, never throwing, and never running
|
|
724
|
+
* anything. Every construct it does not model becomes an `any` atom or an
|
|
725
|
+
* opaque one, both of which only ever make the rules below refuse more.
|
|
726
|
+
*
|
|
727
|
+
* @param {string} source
|
|
728
|
+
* @returns {{root: PatternNode, tooDeep: boolean}}
|
|
729
|
+
*/
|
|
730
|
+
function parsePattern(source) {
|
|
731
|
+
/** @type {{branches: PatternNode[], items: PatternNode[], start: number, bodyStart: number, look: boolean}[]} */
|
|
732
|
+
const stack = [{ branches: [], items: [], start: 0, bodyStart: 0, look: false }];
|
|
733
|
+
let tooDeep = false;
|
|
734
|
+
let i = 0;
|
|
735
|
+
|
|
736
|
+
/** @param {PatternNode} item */
|
|
737
|
+
const push = (item) => stack[stack.length - 1].items.push(item);
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* @param {{branches: PatternNode[], items: PatternNode[], start: number, bodyStart: number, look: boolean}} frame
|
|
741
|
+
* @param {number} end
|
|
742
|
+
*/
|
|
743
|
+
const close = (frame, end) => {
|
|
744
|
+
const tail = seqNode(frame.items, frame.bodyStart, end);
|
|
745
|
+
const body =
|
|
746
|
+
frame.branches.length === 0 ? tail : altNode([...frame.branches, tail], frame.start, end);
|
|
747
|
+
const closed = frame.look ? lookNode(body, frame.start, end) : body;
|
|
748
|
+
// The span is the GROUP's, opening parenthesis included, not the body's:
|
|
749
|
+
// it is what a refusal quotes back, and `a+)+` names nothing a reader can
|
|
750
|
+
// find in their config.
|
|
751
|
+
closed.start = frame.start;
|
|
752
|
+
closed.end = end;
|
|
753
|
+
return closed;
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
while (i < source.length) {
|
|
757
|
+
const ch = source[i];
|
|
758
|
+
if (ch === "\\") {
|
|
759
|
+
const escape = readEscape(source, i);
|
|
760
|
+
push(escape.node);
|
|
761
|
+
i = escape.next;
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
if (ch === "[") {
|
|
765
|
+
const parsed = readClass(source, i);
|
|
766
|
+
push(parsed.node);
|
|
767
|
+
i = parsed.next;
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (ch === "(") {
|
|
771
|
+
if (stack.length >= MAX_GROUP_DEPTH) {
|
|
772
|
+
tooDeep = true;
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
const bodyStart = i + 1 + groupPrefixLength(source, i);
|
|
776
|
+
stack.push({ branches: [], items: [], start: i, bodyStart, look: isLookaround(source, i) });
|
|
777
|
+
i = bodyStart;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (ch === ")") {
|
|
781
|
+
// An unbalanced `)` is a literal here rather than an error: a pattern
|
|
782
|
+
// that is not a valid regular expression at all is reported by
|
|
783
|
+
// `new RegExp` itself a moment later, and reading it as a character can
|
|
784
|
+
// only make this file refuse a pattern that would have been cheap.
|
|
785
|
+
if (stack.length === 1) {
|
|
786
|
+
push(atomNode(oneChar(41), i, i + 1));
|
|
787
|
+
i++;
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
const frame = /** @type {NonNullable<typeof stack[0]>} */ (stack.pop());
|
|
791
|
+
push(close(frame, i + 1));
|
|
792
|
+
i++;
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (ch === "|") {
|
|
796
|
+
const frame = stack[stack.length - 1];
|
|
797
|
+
frame.branches.push(seqNode(frame.items, frame.bodyStart, i));
|
|
798
|
+
frame.items = [];
|
|
799
|
+
frame.bodyStart = i + 1;
|
|
800
|
+
i++;
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
if (ch === "^" || ch === "$") {
|
|
804
|
+
push(anchorNode(ch, i, i + 1));
|
|
805
|
+
i++;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
if (ch === ".") {
|
|
809
|
+
push(atomNode(ANY_CHARS, i, i + 1));
|
|
810
|
+
i++;
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
const quantifier = quantifierAt(source, i);
|
|
814
|
+
if (quantifier === null) {
|
|
815
|
+
const code = source.codePointAt(i) ?? 0;
|
|
816
|
+
const width = String.fromCodePoint(code).length;
|
|
817
|
+
push(atomNode(oneChar(code), i, i + width));
|
|
818
|
+
i += width;
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
let next = i + quantifier.length;
|
|
822
|
+
if (source[next] === "?") next++; // the lazy marker, not a second quantifier
|
|
823
|
+
const frame = stack[stack.length - 1];
|
|
824
|
+
const item = frame.items.pop();
|
|
825
|
+
if (item === undefined) {
|
|
826
|
+
// A quantifier with nothing in front of it is not a valid pattern; read
|
|
827
|
+
// it as the character it is and let `new RegExp` say so.
|
|
828
|
+
push(atomNode(oneChar(source.codePointAt(i) ?? 0), i, i + 1));
|
|
829
|
+
i++;
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
frame.items.push(
|
|
833
|
+
repNode(
|
|
834
|
+
item,
|
|
835
|
+
quantifier.min,
|
|
836
|
+
quantifier.max,
|
|
837
|
+
source.slice(item.start, next),
|
|
838
|
+
item.start,
|
|
839
|
+
next,
|
|
840
|
+
),
|
|
841
|
+
);
|
|
842
|
+
i = next;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
while (stack.length > 1) {
|
|
846
|
+
const frame = /** @type {NonNullable<typeof stack[0]>} */ (stack.pop());
|
|
847
|
+
stack[stack.length - 1].items.push(close(frame, source.length));
|
|
848
|
+
}
|
|
849
|
+
return { root: close(stack[0], source.length), tooDeep };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/* ------------------------------------------------------------------------ *
|
|
853
|
+
* What a failing match costs, read off that shape
|
|
854
|
+
* ------------------------------------------------------------------------ */
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* A sequence read as the chain it actually runs as: bare groups spliced in, so
|
|
858
|
+
* `^.*x(.*y.*z)$` is five links rather than three with two hidden inside one —
|
|
859
|
+
* and a group that runs AT MOST ONCE spliced in too, because `(?:.*)?x` gives
|
|
860
|
+
* that `.*` exactly the same re-splitting to do as `.*x` does. Measured, that
|
|
861
|
+
* hiding place was worth a factor of the subject's length:
|
|
862
|
+
* `[^/]{2,}.?:{1,4}(?:.*)?x$` costs 7.6 SECONDS against a 1024-character
|
|
863
|
+
* subject of colons.
|
|
864
|
+
*
|
|
865
|
+
* What is NOT spliced in is a group that repeats. Its body is one iteration,
|
|
866
|
+
* Rule 1 has already established that the iterations divide only one way, and
|
|
867
|
+
* re-splitting inside one pinned iteration buys the engine nothing — measured,
|
|
868
|
+
* `^\w+(\.\w+)*$` is 0.0ms against every subject built to defeat it.
|
|
869
|
+
*
|
|
870
|
+
* Splicing an optional group in loses its optionality: its parts read as
|
|
871
|
+
* required here. That direction only ever adds requirements, and a requirement
|
|
872
|
+
* only ever makes this file count MORE repetitions, so the error it can cause
|
|
873
|
+
* is a refusal and never an acceptance.
|
|
874
|
+
*
|
|
875
|
+
* @param {PatternNode} candidate
|
|
876
|
+
* @returns {PatternNode[]}
|
|
877
|
+
*/
|
|
878
|
+
function flattenSeq(candidate) {
|
|
879
|
+
if (candidate.kind === "rep" && candidate.maxLen !== 0 && candidate.children[0]) {
|
|
880
|
+
return isRepeated(candidate) ? [candidate] : flattenSeq(candidate.children[0]);
|
|
881
|
+
}
|
|
882
|
+
if (candidate.kind !== "seq") return [candidate];
|
|
883
|
+
/** @type {PatternNode[]} */
|
|
884
|
+
const out = [];
|
|
885
|
+
for (const child of candidate.children) out.push(...flattenSeq(child));
|
|
886
|
+
return out;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** Does this repetition run its body more than once? `{0,1}` and `?` do not. */
|
|
890
|
+
const isRepeated = (element) => element.kind === "rep" && element.repeats;
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* How many repetitions inside `candidate` a requirement OUTSIDE it can force to
|
|
894
|
+
* try every division — every elastic one, except those sealed inside a group
|
|
895
|
+
* that repeats, whose iterations Rule 1 has already pinned.
|
|
896
|
+
*
|
|
897
|
+
* Counted crudely and upward, because the precise question ("which of these
|
|
898
|
+
* could actually trade text with which") is the one the linear chain answers
|
|
899
|
+
* and an alternation is not a linear chain. Measured, the crude count is what
|
|
900
|
+
* a branch really costs: `.*b?([a-z]([^/]{1,4}|[^/]*))xb?$` — one wildcard
|
|
901
|
+
* outside a two-branch group, each branch holding one more — costs 458ms
|
|
902
|
+
* against a 1024-character subject, and 1.15ms with a `^` in front of it.
|
|
903
|
+
*
|
|
904
|
+
* @param {PatternNode} candidate
|
|
905
|
+
* @returns {number}
|
|
906
|
+
*/
|
|
907
|
+
function elasticWithin(candidate) {
|
|
908
|
+
if (isRepeated(candidate)) return candidate.elastic ? 1 : 0;
|
|
909
|
+
const inside = candidate.children.map(elasticWithin);
|
|
910
|
+
const worst =
|
|
911
|
+
candidate.kind === "alt" ? Math.max(0, ...inside) : inside.reduce((a, b) => a + b, 0);
|
|
912
|
+
return worst + (candidate.kind === "rep" && candidate.elastic ? 1 : 0);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Does this element have to match something, and can it fail?
|
|
917
|
+
*
|
|
918
|
+
* `$` is the case the whole calibration turns on. After a repetition that can
|
|
919
|
+
* match every character, `$` cannot fail — the repetition runs to the end of
|
|
920
|
+
* the subject and the anchor succeeds there — so it forces no re-splitting and
|
|
921
|
+
* is not a requirement. After anything else it is one.
|
|
922
|
+
*
|
|
923
|
+
* @param {PatternNode[]} elements
|
|
924
|
+
* @param {number} index
|
|
925
|
+
* @returns {boolean}
|
|
926
|
+
*/
|
|
927
|
+
function isRequirement(elements, index) {
|
|
928
|
+
const element = elements[index];
|
|
929
|
+
if (element.kind === "anchor") {
|
|
930
|
+
if (element.anchor === "^") return false;
|
|
931
|
+
if (element.anchor === "b") return true;
|
|
932
|
+
const previous = elements[index - 1];
|
|
933
|
+
return !(previous?.kind === "rep" && previous.maxLen === Infinity && previous.chars.any);
|
|
934
|
+
}
|
|
935
|
+
if (element.kind === "look") return true;
|
|
936
|
+
return element.minLen >= 1;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** What an element requires FIRST — `any` for the zero-width ones, which nothing here resolves. */
|
|
940
|
+
const requiredFirst = (element) =>
|
|
941
|
+
element.kind === "anchor" || element.kind === "look" ? ANY_CHARS : element.first;
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Would this verdict refuse the pattern it was computed for? Named once so
|
|
945
|
+
* `resplitDegree`'s branch comparison and `regexComplexityError`'s decision
|
|
946
|
+
* cannot drift into disagreeing about which verdicts are refusals.
|
|
947
|
+
*
|
|
948
|
+
* @param {{degree: number, exempt: boolean}} verdict
|
|
949
|
+
* @returns {boolean}
|
|
950
|
+
*/
|
|
951
|
+
const isRefused = (verdict) => verdict.degree > MAX_RESPLIT_REPETITIONS && !verdict.exempt;
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* How many repetitions a failing match can be forced to try every division of
|
|
955
|
+
* the subject across — the exponent of the polynomial, read off the shape.
|
|
956
|
+
*
|
|
957
|
+
* A repetition is counted when both halves of the measurement at the top of
|
|
958
|
+
* this file hold for it: something after it can still fail (otherwise the
|
|
959
|
+
* greedy first attempt is the only one), and it can match the same characters
|
|
960
|
+
* as the next thing that must match (otherwise it has one productive division,
|
|
961
|
+
* which is why `a*b*c*d` is cheap and `.*b.*c.*d` is not).
|
|
962
|
+
*
|
|
963
|
+
* @param {PatternNode} candidate
|
|
964
|
+
* @returns {{degree: number, exempt: boolean, delimiter: number|null}}
|
|
965
|
+
*/
|
|
966
|
+
function resplitDegree(candidate) {
|
|
967
|
+
if (candidate.kind === "alt") {
|
|
968
|
+
// The worst branch, where "worst" is what a REFUSAL turns on rather than
|
|
969
|
+
// the raw number: `^.*\/.*\/.*\/.*$|^a.*a.*a.*z$` has two branches of the
|
|
970
|
+
// same degree, one exempt and one not, and a comparison on the number
|
|
971
|
+
// alone lets whichever came first decide — which is a dangerous
|
|
972
|
+
// alternation accepted because a safe branch stood in front of it. The
|
|
973
|
+
// engine tries every branch, so one costly branch is a costly pattern.
|
|
974
|
+
let worst = { degree: 0, exempt: true, delimiter: /** @type {number|null} */ (null) };
|
|
975
|
+
for (const branch of candidate.children) {
|
|
976
|
+
const found = resplitDegree(branch);
|
|
977
|
+
if (
|
|
978
|
+
isRefused(found)
|
|
979
|
+
? !isRefused(worst) || found.degree > worst.degree
|
|
980
|
+
: !isRefused(worst) && found.degree > worst.degree
|
|
981
|
+
) {
|
|
982
|
+
worst = found;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return worst;
|
|
986
|
+
}
|
|
987
|
+
const elements = flattenSeq(candidate);
|
|
988
|
+
|
|
989
|
+
// An unanchored pattern is retried from every position in the subject, and
|
|
990
|
+
// that retry is a factor of the subject's length like any other. Measured on
|
|
991
|
+
// Node v24.16.0 against a 1024-character subject: `a.*b.*c` costs 61ms and
|
|
992
|
+
// `^a.*b.*c` costs 0.29ms; `.*b?([a-z]([^/]{1,4}|[^/]*))xb?$` costs 458ms
|
|
993
|
+
// and the same pattern with `^` in front costs 1.15ms. `mapGlobToRegExp`
|
|
994
|
+
// anchors what it compiles, so no glob pays this.
|
|
995
|
+
const scan = elements[0]?.anchor === "^" ? 0 : 1;
|
|
996
|
+
|
|
997
|
+
let last = -1;
|
|
998
|
+
for (let i = 0; i < elements.length; i++) if (isRequirement(elements, i)) last = i;
|
|
999
|
+
if (last < 0) return { degree: scan, exempt: false, delimiter: null };
|
|
1000
|
+
|
|
1001
|
+
/** @type {number[]} */
|
|
1002
|
+
const counted = [];
|
|
1003
|
+
let hidden = 0;
|
|
1004
|
+
for (let i = 0; i < last; i++) {
|
|
1005
|
+
const element = elements[i];
|
|
1006
|
+
// An alternation is not a chain, so its branches are counted whole: a
|
|
1007
|
+
// requirement further along the chain can force every elastic repetition
|
|
1008
|
+
// inside the branch that matched, and which branch that is depends on the
|
|
1009
|
+
// subject rather than on the pattern.
|
|
1010
|
+
if (element.kind === "alt" || element.kind === "look") {
|
|
1011
|
+
hidden = Math.max(hidden, elasticWithin(element));
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
if (element.kind !== "rep" || !element.elastic) continue;
|
|
1015
|
+
let next = i + 1;
|
|
1016
|
+
while (next <= last && !isRequirement(elements, next)) next++;
|
|
1017
|
+
// Two ways for a repetition to be re-splittable, and a repetition that is
|
|
1018
|
+
// neither has one productive division and costs nothing. It can trade text
|
|
1019
|
+
// with the atom it has to hand over to — `.*` in front of a `/` — or with
|
|
1020
|
+
// an elastic NEIGHBOUR, nothing required in between, which is what
|
|
1021
|
+
// `[a-z]+b+[a-z]{2,}` is three of: measured, that chain in front of a
|
|
1022
|
+
// `\d{2,}` that never matches costs 188ms against a 1024-character subject
|
|
1023
|
+
// of `b`s where each of its parts alone costs nothing. `a*b*c*d` is the
|
|
1024
|
+
// same shape with none of the overlap, and is quadratic rather than
|
|
1025
|
+
// quartic for exactly that reason.
|
|
1026
|
+
if (charsOverlap(element.chars, requiredFirst(elements[next]))) counted.push(i);
|
|
1027
|
+
else if (tradesWithNeighbour(elements, i, last)) counted.push(i);
|
|
1028
|
+
}
|
|
1029
|
+
const degree = counted.length + hidden + scan;
|
|
1030
|
+
|
|
1031
|
+
// The single-delimiter exemption: every atom required from the first counted
|
|
1032
|
+
// repetition onward is the SAME one character, and a repetition that can
|
|
1033
|
+
// match anything follows the last of them. Failure then means the subject is
|
|
1034
|
+
// short of that character, and a subject short of it has too few places to
|
|
1035
|
+
// try — the argument `MAX_DELIMITED_SEGMENTS` carries, and the reason
|
|
1036
|
+
// `^.*\/.*\/.*\/.*$` is free while `^.*c.*c.*c$`, which has no such tail,
|
|
1037
|
+
// costs 773ms against a 2000-character subject.
|
|
1038
|
+
let delimiter = null;
|
|
1039
|
+
let uniform = hidden === 0 && counted.length > 0;
|
|
1040
|
+
for (let i = counted[0] ?? 0; uniform && i <= last; i++) {
|
|
1041
|
+
if (!isRequirement(elements, i)) continue;
|
|
1042
|
+
const element = elements[i];
|
|
1043
|
+
const code = element.kind === "rep" || element.kind === "atom" ? loneChar(element.chars) : null;
|
|
1044
|
+
if (code === null || element.minLen !== 1 || element.maxLen !== 1) uniform = false;
|
|
1045
|
+
else if (delimiter === null) delimiter = code;
|
|
1046
|
+
else if (delimiter !== code) uniform = false;
|
|
1047
|
+
}
|
|
1048
|
+
const absorbs = elements
|
|
1049
|
+
.slice(last + 1)
|
|
1050
|
+
.some((element) => element.kind === "rep" && element.maxLen === Infinity && element.chars.any);
|
|
1051
|
+
const exempt = uniform && absorbs && degree <= MAX_DELIMITED_SEGMENTS;
|
|
1052
|
+
return { degree, exempt, delimiter: exempt ? delimiter : null };
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Can the repetition at `index` trade the text it consumes with an elastic
|
|
1057
|
+
* repetition beside it — nothing that must match in between, and character
|
|
1058
|
+
* sets that overlap?
|
|
1059
|
+
*
|
|
1060
|
+
* Looked for in both directions, because either neighbour makes the pair a
|
|
1061
|
+
* choice point: `[a-z]+b+` divides a run of `b`s any way it likes, and adding
|
|
1062
|
+
* a third overlapping repetition adds a whole factor of the subject's length
|
|
1063
|
+
* to what a failing tail costs.
|
|
1064
|
+
*
|
|
1065
|
+
* @param {PatternNode[]} elements
|
|
1066
|
+
* @param {number} index
|
|
1067
|
+
* @param {number} last Index of the last requirement — nothing past it can
|
|
1068
|
+
* force a retry, so nothing past it is a neighbour worth counting.
|
|
1069
|
+
* @returns {boolean}
|
|
1070
|
+
*/
|
|
1071
|
+
function tradesWithNeighbour(elements, index, last) {
|
|
1072
|
+
const subject = elements[index];
|
|
1073
|
+
for (const step of [-1, 1]) {
|
|
1074
|
+
for (let i = index + step; i >= 0 && i <= last; i += step) {
|
|
1075
|
+
const neighbour = elements[i];
|
|
1076
|
+
if (neighbour.kind === "rep" && neighbour.elastic) {
|
|
1077
|
+
if (charsOverlap(subject.chars, neighbour.chars)) return true;
|
|
1078
|
+
}
|
|
1079
|
+
// Anything that must match its own text stands between them, and the
|
|
1080
|
+
// pair can no longer shift the boundary past it.
|
|
1081
|
+
if (neighbour.minLen >= 1 && !(neighbour.kind === "rep" && neighbour.elastic)) break;
|
|
1082
|
+
if (neighbour.kind === "anchor" || neighbour.kind === "look") break;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return false;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Why `source` cannot be compiled and run as a regular expression here, or
|
|
1090
|
+
* `null`.
|
|
1091
|
+
*
|
|
1092
|
+
* Two refusals, both structural — read off the pattern's SHAPE, never by
|
|
1093
|
+
* running it against a subject and never by timing anything:
|
|
1094
|
+
*
|
|
1095
|
+
* 1. **A repetition may not be applied to a group that can match the same text
|
|
1096
|
+
* more than one way.** `(a+)+`, `(a*)*`, `(a|aa)+`, `([a-z]+)*` are all the
|
|
1097
|
+
* same defect: the group's own body leaves the engine a choice about where
|
|
1098
|
+
* one iteration ends and the next begins, so a subject that never matches
|
|
1099
|
+
* costs four times more for every two characters it grows. `isPinned`
|
|
1100
|
+
* above carries the three tests that decide it, and the reason it is
|
|
1101
|
+
* ambiguity rather than "there is a quantifier in there" — `^\w+(\.\w+)*$`
|
|
1102
|
+
* has one and is instant, because `\.` and `\w` cannot match the same
|
|
1103
|
+
* character.
|
|
1104
|
+
* 2. **At most `MAX_RESPLIT_REPETITIONS` repetitions may be re-split by a
|
|
1105
|
+
* requirement that can fail**, which bounds the polynomial the first rule
|
|
1106
|
+
* does not reach — `resplitDegree` above carries the counting, and the
|
|
1107
|
+
* constants at the top of this file carry the measurement.
|
|
1108
|
+
*
|
|
1109
|
+
* Both are refusals of a SHAPE, not of a slow run: the criterion is stated
|
|
1110
|
+
* here, decided at config load, and identical on every machine — the same
|
|
1111
|
+
* discipline `globComplexityError` applies to the fourth dialect at the bottom
|
|
1112
|
+
* of this file. A pattern this refuses is not a pattern this engine matches
|
|
1113
|
+
* slowly; it is one no run ever starts.
|
|
1114
|
+
*
|
|
1115
|
+
* The model is deliberately more suspicious than a regex parser: a construct
|
|
1116
|
+
* it does not resolve — a negated class, a property escape, a backreference,
|
|
1117
|
+
* an unbalanced `)` — becomes "matches anything" or "cannot be pinned", and
|
|
1118
|
+
* both of those can only make it refuse a pattern that would have been cheap.
|
|
1119
|
+
* A pattern that is not a valid regular expression at all is reported by
|
|
1120
|
+
* `new RegExp` itself a moment later.
|
|
1121
|
+
*
|
|
1122
|
+
* @param {string} source Exactly the string that will be handed to `RegExp`.
|
|
1123
|
+
* @returns {string|null}
|
|
1124
|
+
*/
|
|
1125
|
+
export function regexComplexityError(source) {
|
|
1126
|
+
const { root, tooDeep } = parsePattern(source);
|
|
1127
|
+
if (tooDeep) {
|
|
1128
|
+
return (
|
|
1129
|
+
`nests groups more than ${MAX_GROUP_DEPTH} deep, which this engine reads no further into ` +
|
|
1130
|
+
`— past that depth it cannot tell a cheap pattern from one that never returns, and ` +
|
|
1131
|
+
`guessing in that direction is how a boundary check stops running. Flatten the groups`
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
if (root.problem) return root.problem;
|
|
1135
|
+
const verdict = resplitDegree(root);
|
|
1136
|
+
return isRefused(verdict) ? resplitReason(verdict.degree) : null;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** The first refusal's message, naming the sub-pattern that earned it. */
|
|
1140
|
+
function nestedRepetitionReason(offender) {
|
|
1141
|
+
return (
|
|
1142
|
+
`repeats a group that can match the same text more than one way ('${offender}'), which ` +
|
|
1143
|
+
`backtracks exponentially on a subject that does not match: measured on Node v24.16.0 ` +
|
|
1144
|
+
`against '(a+)+$', 12ms at a 20-character import specifier, 201ms at 24, 775ms at 26 and ` +
|
|
1145
|
+
`12.4 SECONDS at 30 — four times the work for every two characters after that, so 40 ` +
|
|
1146
|
+
`characters is hours and 50 is months, from one config value and one specifier this tool ` +
|
|
1147
|
+
`does not control. A repeated group has to be pinned: every iteration the same length ` +
|
|
1148
|
+
`('(ab)+', '(a|b)+'), or opening with a fixed character that appears nowhere else inside ` +
|
|
1149
|
+
`it ('(\\.\\w+)*')`
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** The second refusal's message. */
|
|
1154
|
+
function resplitReason(degree) {
|
|
1155
|
+
return (
|
|
1156
|
+
`re-splits ${degree} repetitions against a requirement that can fail, more than the ` +
|
|
1157
|
+
`${MAX_RESPLIT_REPETITIONS} this engine will run — each one multiplies the work an import ` +
|
|
1158
|
+
`specifier that does NOT match costs, and specifiers come from source files this tool does ` +
|
|
1159
|
+
`not control (measured on Node v24.16.0: '^a.*a.*a.*z$' costs 190ms against a ` +
|
|
1160
|
+
`1000-character specifier and 1.5 seconds against 2000, where '^a.*a.*z$' costs 2.3ms and ` +
|
|
1161
|
+
`'^.*-.*-.*$', which ends in a wildcard nothing can fail after, costs 0.0ms at every ` +
|
|
1162
|
+
`length). End the pattern in its last wildcard, or narrow it`
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Why `specifier` cannot be matched against a consumer-written pattern, or
|
|
1168
|
+
* `null`.
|
|
1169
|
+
*
|
|
1170
|
+
* The pattern guards above bound the SHAPE; this bounds the subject, and both
|
|
1171
|
+
* are needed because the cost is the product. `MAX_SPECIFIER_LENGTH` carries
|
|
1172
|
+
* the measurement.
|
|
1173
|
+
*
|
|
1174
|
+
* @param {string} specifier
|
|
1175
|
+
* @returns {string|null}
|
|
1176
|
+
*/
|
|
1177
|
+
export function specifierLengthError(specifier) {
|
|
1178
|
+
if (specifier.length <= MAX_SPECIFIER_LENGTH) return null;
|
|
1179
|
+
return (
|
|
1180
|
+
`is ${specifier.length} characters, past the ${MAX_SPECIFIER_LENGTH} this engine will match ` +
|
|
1181
|
+
`a boundary pattern against — every pattern that survives config load is still quadratic in ` +
|
|
1182
|
+
`the length of a specifier that does not match, and nothing upstream of here bounds that ` +
|
|
1183
|
+
`length. It begins '${specifier.slice(0, 60)}'`
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* The same bound, as the throw that makes an unjudged site loud.
|
|
1189
|
+
*
|
|
1190
|
+
* Refusing to match and returning `false` would be the silent direction: no
|
|
1191
|
+
* violation reported for a specifier nothing looked at, byte-for-byte a clean
|
|
1192
|
+
* site (`../../../../AGENTS.md`). This throws instead, which `cli.mjs check`
|
|
1193
|
+
* turns into exit 3 — the run could not complete — the same class a malformed
|
|
1194
|
+
* config or a missing graph lands in, and the same thing `../rules/index.mjs`
|
|
1195
|
+
* does for a record its analyzer left incomplete.
|
|
1196
|
+
*
|
|
1197
|
+
* @param {string} specifier
|
|
1198
|
+
* @param {string} where What was being matched, for the message.
|
|
1199
|
+
* @returns {void}
|
|
1200
|
+
*/
|
|
1201
|
+
export function assertMatchableSpecifier(specifier, where) {
|
|
1202
|
+
const problem = specifierLengthError(specifier);
|
|
1203
|
+
if (problem) throw new Error(`archkeep: the ${where} ${problem}`);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* A refusal by `regexComplexityError`, carrying the half of the message the
|
|
1208
|
+
* `…Error` helpers return so a config-load report reads as one sentence about
|
|
1209
|
+
* the row rather than an exception quoted inside another exception.
|
|
1210
|
+
*/
|
|
1211
|
+
class RegexComplexityError extends Error {
|
|
1212
|
+
/**
|
|
1213
|
+
* @param {string} reason
|
|
1214
|
+
* @param {string} message
|
|
1215
|
+
*/
|
|
1216
|
+
constructor(reason, message) {
|
|
1217
|
+
super(message);
|
|
1218
|
+
this.name = "RegexComplexityError";
|
|
1219
|
+
this.reason = reason;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* `new RegExp(source)`, refused first by `regexComplexityError` — the same
|
|
1225
|
+
* arrangement `safeMatchesGlob` has with `globComplexityError` at the bottom
|
|
1226
|
+
* of this file, and for the same reason: a caller that skipped config-load
|
|
1227
|
+
* validation, or a future one that never validates, still cannot reach the
|
|
1228
|
+
* expensive call.
|
|
1229
|
+
*
|
|
1230
|
+
* @param {string} source
|
|
1231
|
+
* @param {string} label What the pattern is and how it was written, for the
|
|
1232
|
+
* thrown message — the compiled `source` is not always what the consumer
|
|
1233
|
+
* typed (`mapGlobToRegExp` maps stars first).
|
|
1234
|
+
* @returns {RegExp}
|
|
1235
|
+
*/
|
|
1236
|
+
function guardedRegExp(source, label) {
|
|
1237
|
+
const problem = regexComplexityError(source);
|
|
1238
|
+
if (problem) throw new RegexComplexityError(problem, `archkeep: ${label} ${problem}`);
|
|
1239
|
+
return new RegExp(source);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* The reason a `…Error` helper reports for a pattern its matcher threw on:
|
|
1244
|
+
* the structural refusal as one sentence, or the compiler's own complaint.
|
|
1245
|
+
*
|
|
1246
|
+
* @param {unknown} cause
|
|
1247
|
+
* @param {string} prefix
|
|
1248
|
+
* @returns {string}
|
|
1249
|
+
*/
|
|
1250
|
+
function patternReason(cause, prefix) {
|
|
1251
|
+
if (cause instanceof RegexComplexityError) return cause.reason;
|
|
1252
|
+
return `${prefix}: ${/** @type {Error} */ (cause)?.message ?? cause}`;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Does `extractedImport` match the wildcard pattern `allowableImport`?
|
|
1257
|
+
*
|
|
1258
|
+
* Port of `matchImportWithWildcard` in `@nx/eslint-plugin`'s
|
|
1259
|
+
* `utils/runtime-lint-utils`. The final branch is a bare, unanchored RegExp —
|
|
1260
|
+
* that is upstream's behaviour and the reason `allow` entries are far broader
|
|
1261
|
+
* than they read.
|
|
1262
|
+
*
|
|
1263
|
+
* The subject is bounded here rather than in the branch that compiles one:
|
|
1264
|
+
* this is a door a specifier arrives at, and one rule about what may come
|
|
1265
|
+
* through a door is easier to hold than four rules about what each branch does
|
|
1266
|
+
* with it.
|
|
1267
|
+
*
|
|
1268
|
+
* @param {string} allowableImport May contain `*`; may be a regular expression.
|
|
1269
|
+
* @param {string} extractedImport The raw specifier, as written.
|
|
1270
|
+
* @returns {boolean}
|
|
1271
|
+
* @throws {Error} when `extractedImport` is past `MAX_SPECIFIER_LENGTH`.
|
|
1272
|
+
*/
|
|
1273
|
+
export function matchImportWithWildcard(allowableImport, extractedImport) {
|
|
1274
|
+
assertMatchableSpecifier(
|
|
1275
|
+
extractedImport,
|
|
1276
|
+
`specifier matched against import pattern '${allowableImport}'`,
|
|
1277
|
+
);
|
|
1278
|
+
if (allowableImport.endsWith("/**")) {
|
|
1279
|
+
const prefix = allowableImport.substring(0, allowableImport.length - 2);
|
|
1280
|
+
return extractedImport.startsWith(prefix);
|
|
1281
|
+
} else if (allowableImport.endsWith("/*")) {
|
|
1282
|
+
const prefix = allowableImport.substring(0, allowableImport.length - 1);
|
|
1283
|
+
if (!extractedImport.startsWith(prefix)) return false;
|
|
1284
|
+
return extractedImport.substring(prefix.length).indexOf("/") === -1;
|
|
1285
|
+
} else if (allowableImport.indexOf("/**/") > -1) {
|
|
1286
|
+
const [prefix, suffix] = allowableImport.split("/**/");
|
|
1287
|
+
return extractedImport.startsWith(prefix) && extractedImport.endsWith(suffix);
|
|
1288
|
+
} else {
|
|
1289
|
+
return guardedRegExp(allowableImport, `import pattern '${allowableImport}'`).test(
|
|
1290
|
+
extractedImport,
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Turns an import definition into the anchored RegExp Nx tests it with.
|
|
1297
|
+
*
|
|
1298
|
+
* Port of `mapGlobToRegExp`. The double construction is upstream's: the inner
|
|
1299
|
+
* `RegExp` normalises the source (escaping `/`) before the outer one anchors
|
|
1300
|
+
* it, and reproducing it matters because the two produce different sources.
|
|
1301
|
+
*
|
|
1302
|
+
* @param {string} importDefinition
|
|
1303
|
+
* @returns {RegExp}
|
|
1304
|
+
*/
|
|
1305
|
+
export function mapGlobToRegExp(importDefinition) {
|
|
1306
|
+
// Every instance of `*`, `**..*` and `.*` becomes `.*` — upstream's comment.
|
|
1307
|
+
const mappedWildcards = importDefinition.split(/(?:\.\*)|\*+/).join(".*");
|
|
1308
|
+
// Guarded on the ANCHORED source, which is exactly what gets run — not on
|
|
1309
|
+
// the raw spelling, which would refuse `@scope/**` for carrying two stars
|
|
1310
|
+
// where the engine sees one `.*`, and not on the intermediate either: the
|
|
1311
|
+
// anchors are half of what makes a glob cheap. `^…$` is the difference
|
|
1312
|
+
// between a last wildcard nothing can fail after and one a start position
|
|
1313
|
+
// can be retried in front of, which is the difference between 0.0ms and
|
|
1314
|
+
// seconds (`MAX_RESPLIT_REPETITIONS`). Compiling the intermediate first is
|
|
1315
|
+
// upstream's own double construction — it normalises the source, escaping
|
|
1316
|
+
// `/` — and compiling never runs anything.
|
|
1317
|
+
const source = new RegExp(mappedWildcards).source;
|
|
1318
|
+
return guardedRegExp(`^${source}$`, `glob pattern '${importDefinition}'`);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Does a tag list satisfy one constraint tag? The core of upstream's `hasTag`,
|
|
1323
|
+
* taken over the tag array rather than a project node so it can be reused for
|
|
1324
|
+
* both source matching and target matching.
|
|
1325
|
+
*
|
|
1326
|
+
* Four dialects, in upstream's order: `*` matches everything (so a single
|
|
1327
|
+
* `{ sourceTag: "*" }` row disarms the no-constraint-is-an-error rule for the
|
|
1328
|
+
* whole workspace), `/…/` is a regular expression tested against each tag,
|
|
1329
|
+
* anything containing `*` is a `mapGlobToRegExp` glob, and everything else is
|
|
1330
|
+
* an exact string comparison.
|
|
1331
|
+
*
|
|
1332
|
+
* @param {string[]} tags The project's tags.
|
|
1333
|
+
* @param {string} tag The constraint's tag.
|
|
1334
|
+
* @returns {boolean}
|
|
1335
|
+
*/
|
|
1336
|
+
export function tagMatches(tags, tag) {
|
|
1337
|
+
if (tag === "*") return true;
|
|
1338
|
+
if (tag.startsWith("/") && tag.endsWith("/")) {
|
|
1339
|
+
const regex = guardedRegExp(tag.substring(1, tag.length - 1), `tag pattern '${tag}'`);
|
|
1340
|
+
return tags.some((t) => regex.test(t));
|
|
1341
|
+
}
|
|
1342
|
+
if (tag.includes("*")) {
|
|
1343
|
+
const regex = mapGlobToRegExp(tag);
|
|
1344
|
+
return tags.some((t) => regex.test(t));
|
|
1345
|
+
}
|
|
1346
|
+
return tags.indexOf(tag) > -1;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** Why `pattern` cannot serve as an `allow`-style import pattern, or `null`. */
|
|
1350
|
+
export function importPatternError(pattern) {
|
|
1351
|
+
try {
|
|
1352
|
+
matchImportWithWildcard(pattern, "");
|
|
1353
|
+
return null;
|
|
1354
|
+
} catch (cause) {
|
|
1355
|
+
return patternReason(cause, "is not a valid import pattern");
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/** Why `pattern` cannot serve as an external-import glob, or `null`. */
|
|
1360
|
+
export function globPatternError(pattern) {
|
|
1361
|
+
try {
|
|
1362
|
+
mapGlobToRegExp(pattern);
|
|
1363
|
+
return null;
|
|
1364
|
+
} catch (cause) {
|
|
1365
|
+
return patternReason(cause, "is not a valid import glob");
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/** Why `tag` cannot serve as a constraint tag, or `null`. */
|
|
1370
|
+
export function tagPatternError(tag) {
|
|
1371
|
+
try {
|
|
1372
|
+
tagMatches([], tag);
|
|
1373
|
+
return null;
|
|
1374
|
+
} catch (cause) {
|
|
1375
|
+
return patternReason(cause, "is not a valid tag pattern");
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* The glob metacharacters Nx hands to minimatch and this engine deliberately
|
|
1381
|
+
* does not reimplement. A bare `*` is exempt: upstream short-circuits that one
|
|
1382
|
+
* before minimatch ever sees it, so "every project" is reproducible exactly.
|
|
1383
|
+
*
|
|
1384
|
+
* Exported so `../config.mjs` can reject the same characters in
|
|
1385
|
+
* `buildTargets` entries — a target NAME containing a glob character can
|
|
1386
|
+
* never match a target under `hasBuildExecutor`'s exact `===` lookup, so it
|
|
1387
|
+
* is refused at load rather than silently selecting nothing
|
|
1388
|
+
* (`../../../../docs/reference/policy-schema.md`, "`moduleBoundaryOptions`").
|
|
1389
|
+
*/
|
|
1390
|
+
export const GLOB_METACHARACTERS = /[*?[\]{}()]/;
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Why `pattern` cannot be used to select projects here, or `null`.
|
|
1394
|
+
*
|
|
1395
|
+
* Nx resolves these patterns with minimatch, which this project may not import
|
|
1396
|
+
* (Node built-ins and `typescript` only). Rather than hand-roll an
|
|
1397
|
+
* almost-minimatch — an ignore list that expands to nearly the right set is a
|
|
1398
|
+
* false negative generator, and `ignoredCircularDependencies` is the one option
|
|
1399
|
+
* whose whole job is to suppress a violation — the unreproducible subset is
|
|
1400
|
+
* rejected at config load, naming the entry. Refusing to start beats starting
|
|
1401
|
+
* with an ignore list that means something slightly different here than it does
|
|
1402
|
+
* in ESLint.
|
|
1403
|
+
*
|
|
1404
|
+
* @param {string} pattern
|
|
1405
|
+
* @returns {string|null}
|
|
1406
|
+
*/
|
|
1407
|
+
export function projectPatternError(pattern) {
|
|
1408
|
+
const value = pattern.startsWith("!") ? pattern.slice(1) : pattern;
|
|
1409
|
+
const withoutLabel = value.includes(":") ? value.slice(value.indexOf(":") + 1) : value;
|
|
1410
|
+
if (withoutLabel === "*") return null;
|
|
1411
|
+
if (GLOB_METACHARACTERS.test(withoutLabel)) {
|
|
1412
|
+
return (
|
|
1413
|
+
`uses glob syntax this engine does not reproduce — Nx expands it with minimatch, ` +
|
|
1414
|
+
`which this tool cannot import, and an ignore list that expands to almost the right ` +
|
|
1415
|
+
`set silently hides real cycles. Name projects, tags or directories exactly, or '*'`
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
return null;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** A pattern's `{type, value, exclude}`, as `parseStringPattern` splits it. */
|
|
1422
|
+
const VALID_PATTERN_TYPES = ["name", "tag", "directory", "unlabeled"];
|
|
1423
|
+
|
|
1424
|
+
function parseStringPattern(pattern, nodes) {
|
|
1425
|
+
const exclude = pattern.startsWith("!");
|
|
1426
|
+
const body = exclude ? pattern.substring(1) : pattern;
|
|
1427
|
+
const separator = body.indexOf(":");
|
|
1428
|
+
if (nodes[body]) return { type: "name", value: body, exclude };
|
|
1429
|
+
if (separator === -1) return { type: "unlabeled", value: body, exclude };
|
|
1430
|
+
const potentialType = body.substring(0, separator);
|
|
1431
|
+
return {
|
|
1432
|
+
type: VALID_PATTERN_TYPES.includes(potentialType) ? potentialType : "unlabeled",
|
|
1433
|
+
value: body.substring(separator + 1),
|
|
1434
|
+
exclude,
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
function applyName(nodes, pattern, matched) {
|
|
1439
|
+
if (nodes[pattern.value]) {
|
|
1440
|
+
if (pattern.exclude) matched.delete(pattern.value);
|
|
1441
|
+
else matched.add(pattern.value);
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
// Upstream's own regex: `\b` widened to treat `-` as a boundary and `_` as
|
|
1445
|
+
// not one, so `foo` selects `foo_bar` but not `foo-e2e`. Case-insensitive.
|
|
1446
|
+
const regex = new RegExp(`(?<![@a-zA-Z0-9-])${pattern.value}(?![@a-zA-Z0-9-])`, "i");
|
|
1447
|
+
for (const name of Object.keys(nodes)) {
|
|
1448
|
+
if (!regex.test(name)) continue;
|
|
1449
|
+
if (pattern.exclude) matched.delete(name);
|
|
1450
|
+
else matched.add(name);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function applyDirectory(nodes, pattern, matched) {
|
|
1455
|
+
for (const [name, node] of Object.entries(nodes)) {
|
|
1456
|
+
// Exact root comparison where Nx globs. A strict subset, and the direction
|
|
1457
|
+
// is safe for the only caller: fewer ignored pairs means more cycles
|
|
1458
|
+
// reported, never fewer.
|
|
1459
|
+
if (node.data?.root !== pattern.value) continue;
|
|
1460
|
+
if (pattern.exclude) matched.delete(name);
|
|
1461
|
+
else matched.add(name);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function applyTag(nodes, pattern, matched) {
|
|
1466
|
+
for (const [name, node] of Object.entries(nodes)) {
|
|
1467
|
+
if (!(node.data?.tags || []).includes(pattern.value)) continue;
|
|
1468
|
+
if (pattern.exclude) matched.delete(name);
|
|
1469
|
+
else matched.add(name);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Project names selected by a list of patterns — the subset of Nx's
|
|
1475
|
+
* `findMatchingProjects` this engine reproduces exactly. Patterns outside that
|
|
1476
|
+
* subset are rejected earlier by `projectPatternError`, so reaching one here is
|
|
1477
|
+
* a caller that skipped validation and it throws rather than guessing.
|
|
1478
|
+
*
|
|
1479
|
+
* @param {string[]} patterns
|
|
1480
|
+
* @param {Record<string, {data?: {root?: string, tags?: string[]}}>} nodes
|
|
1481
|
+
* @returns {string[]}
|
|
1482
|
+
*/
|
|
1483
|
+
export function findMatchingProjects(patterns, nodes) {
|
|
1484
|
+
if (!patterns.length || patterns.filter((p) => p.length).length === 0) return [];
|
|
1485
|
+
const matched = new Set();
|
|
1486
|
+
// A list opening with an exclusion means "everything except…", so Nx prepends
|
|
1487
|
+
// a wildcard. Reproduced because it changes the result set entirely.
|
|
1488
|
+
const effective = patterns[0].startsWith("!") ? ["*", ...patterns] : patterns;
|
|
1489
|
+
|
|
1490
|
+
for (const stringPattern of effective) {
|
|
1491
|
+
if (!stringPattern.length || stringPattern.startsWith("nx-cloud:")) continue;
|
|
1492
|
+
const unsupported = projectPatternError(stringPattern);
|
|
1493
|
+
if (unsupported) {
|
|
1494
|
+
throw new Error(`archkeep: project pattern '${stringPattern}' ${unsupported}`);
|
|
1495
|
+
}
|
|
1496
|
+
const pattern = parseStringPattern(stringPattern, nodes);
|
|
1497
|
+
if (pattern.value === "*") {
|
|
1498
|
+
for (const name of Object.keys(nodes)) {
|
|
1499
|
+
if (pattern.exclude) matched.delete(name);
|
|
1500
|
+
else matched.add(name);
|
|
1501
|
+
}
|
|
1502
|
+
continue;
|
|
1503
|
+
}
|
|
1504
|
+
if (pattern.type === "tag") {
|
|
1505
|
+
applyTag(nodes, pattern, matched);
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (pattern.type === "name") {
|
|
1509
|
+
applyName(nodes, pattern, matched);
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
if (pattern.type === "directory") {
|
|
1513
|
+
applyDirectory(nodes, pattern, matched);
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
// Unlabeled waterfalls: names first, directories only if nothing matched.
|
|
1517
|
+
const before = matched.size;
|
|
1518
|
+
applyName(nodes, pattern, matched);
|
|
1519
|
+
if (matched.size !== before) continue;
|
|
1520
|
+
applyDirectory(nodes, pattern, matched);
|
|
1521
|
+
}
|
|
1522
|
+
return Array.from(matched);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* The most brace-driven alternatives one glob pattern may expand to before
|
|
1527
|
+
* `globComplexityError` refuses it — chosen from measurement, not guessed.
|
|
1528
|
+
*
|
|
1529
|
+
* Against this engine's own `path.posix.matchesGlob` (Node 22.22.2, ordinary
|
|
1530
|
+
* hardware): thirteen sequential two-way brace groups
|
|
1531
|
+
* (`{a0,b0}{a1,b1}…{a12,b12}`, an expansion count of 2**13 = 8192) already
|
|
1532
|
+
* cost around 600ms in a single call, and three groups of forty alternatives
|
|
1533
|
+
* each (an expansion count of 40**3 = 64000) cost around 21 SECONDS — the
|
|
1534
|
+
* cost grows far faster than the expansion count does, and the same way
|
|
1535
|
+
* whether the alternatives arrive as many small groups or a few large ones.
|
|
1536
|
+
* Capping the count at 512 keeps an ALLOWED pattern's worst single call under
|
|
1537
|
+
* ten milliseconds in that same measurement, with generous headroom over
|
|
1538
|
+
* anything a real suppression, exemption or project rule plausibly needs to
|
|
1539
|
+
* name from ONE glob string.
|
|
1540
|
+
*/
|
|
1541
|
+
export const MAX_GLOB_EXPANSIONS = 512;
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* A brace group's content matches one of these two shapes instead of a
|
|
1545
|
+
* `,`-separated union: `path.posix.matchesGlob` treats `{start..end}` and
|
|
1546
|
+
* `{start..end..step}` as a RANGE, fully expanding every integer (or, with
|
|
1547
|
+
* single letters on both sides, every character) from `start` to `end` —
|
|
1548
|
+
* `{1..300000}` is 300000 alternatives from a comma-free 12-character
|
|
1549
|
+
* pattern, which `braceExpansionCount` would previously see as one
|
|
1550
|
+
* alternative (no `,` inside) and let straight through the cap. Both are
|
|
1551
|
+
* exact-match patterns (no partial match inside a longer group content, the
|
|
1552
|
+
* same way a real range only fires when it is the group's entire body) and
|
|
1553
|
+
* both accept a signed step so a descending range (`{10..1}`) still matches.
|
|
1554
|
+
*/
|
|
1555
|
+
const NUMERIC_RANGE_PATTERN = /^(-?\d+)\.\.(-?\d+)(?:\.\.(-?\d+))?$/;
|
|
1556
|
+
const ALPHA_RANGE_PATTERN = /^([A-Za-z])\.\.([A-Za-z])(?:\.\.(-?\d+))?$/;
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* How many literal strings a brace group's raw `content` (the text strictly
|
|
1560
|
+
* between one matched `{` and `}`, before any nested group inside it is
|
|
1561
|
+
* resolved) expands to if — and only if — that content is a `{start..end}`
|
|
1562
|
+
* or `{start..end..step}` range in its entirety. `null` when it is not a
|
|
1563
|
+
* range at all (a comma union, a nested group, or a literal), so the caller
|
|
1564
|
+
* falls back to the comma-counting arithmetic that already handles those.
|
|
1565
|
+
*
|
|
1566
|
+
* The cardinality is `floor(|end-start|/max(1,|step|)) + 1` — the same count
|
|
1567
|
+
* real brace expansion produces — computed from the endpoints alone, never by
|
|
1568
|
+
* generating the range: the number can be arbitrarily large (`{1..300000}`)
|
|
1569
|
+
* while this stays a handful of arithmetic operations. `max(1, |step|)`
|
|
1570
|
+
* absorbs a step of `0`, which the real matcher rejects outright — treating
|
|
1571
|
+
* it as step `1` only ever overcounts against that error, the safe direction
|
|
1572
|
+
* for a guard that must never return fewer alternatives than the real
|
|
1573
|
+
* expansion would.
|
|
1574
|
+
*
|
|
1575
|
+
* @param {string} content
|
|
1576
|
+
* @returns {number|null}
|
|
1577
|
+
*/
|
|
1578
|
+
function rangeCardinality(content) {
|
|
1579
|
+
const numeric = NUMERIC_RANGE_PATTERN.exec(content);
|
|
1580
|
+
const alpha = numeric ? null : ALPHA_RANGE_PATTERN.exec(content);
|
|
1581
|
+
const match = numeric ?? alpha;
|
|
1582
|
+
if (!match) return null;
|
|
1583
|
+
const start = numeric ? Number(match[1]) : match[1].charCodeAt(0);
|
|
1584
|
+
const end = numeric ? Number(match[2]) : match[2].charCodeAt(0);
|
|
1585
|
+
const step = match[3] === undefined ? 1 : Number(match[3]);
|
|
1586
|
+
return Math.floor(Math.abs(end - start) / Math.max(1, Math.abs(step))) + 1;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* The number of literal strings `pattern`'s brace groups would expand to,
|
|
1591
|
+
* without ever generating them: multiplied across a concatenation (`{a,b}c`
|
|
1592
|
+
* is 2, `{a,b}{c,d}` is 4), summed across a `,`-separated union (`{a,{b,c}}`
|
|
1593
|
+
* is 2 — `a`, plus the nested pair), and — per `rangeCardinality` above —
|
|
1594
|
+
* folded in for a `{start..end}`/`{start..end..step}` range exactly the way
|
|
1595
|
+
* a comma union would be, the same arithmetic real brace expansion performs,
|
|
1596
|
+
* so the number returned is never smaller than what `path.posix.matchesGlob`
|
|
1597
|
+
* would actually have to work through.
|
|
1598
|
+
*
|
|
1599
|
+
* An explicit stack, not recursion: a pattern built from thousands of nested
|
|
1600
|
+
* `{` would otherwise make counting itself recurse thousands of frames deep,
|
|
1601
|
+
* trading the DoS this function exists to prevent for a stack overflow
|
|
1602
|
+
* instead. Saturates at `cap + 1` the moment any open frame's own running
|
|
1603
|
+
* total would exceed `cap`, so neither a wide pattern (many alternatives) nor
|
|
1604
|
+
* a deep one (many nested groups) costs this function more than the linear
|
|
1605
|
+
* scan already in flight — the property `globComplexityError` needs from it
|
|
1606
|
+
* to be a cheap gate in front of the real, unbounded matcher.
|
|
1607
|
+
*
|
|
1608
|
+
* An unbalanced `{` with no closing `}` is folded in as though the string had
|
|
1609
|
+
* ended there. This function only ever needs to REFUSE a pattern that really
|
|
1610
|
+
* would explode, never to reproduce `path.posix.matchesGlob`'s exact grammar
|
|
1611
|
+
* (escapes, a `{`/`}` meant literally inside a `[...]` class) — so treating
|
|
1612
|
+
* anything that looks like brace syntax as brace syntax is the safe
|
|
1613
|
+
* direction: it can only make this function refuse a pattern that would
|
|
1614
|
+
* actually have been cheap, never the reverse.
|
|
1615
|
+
*
|
|
1616
|
+
* @param {string} pattern
|
|
1617
|
+
* @param {number} cap
|
|
1618
|
+
* @returns {number} The exact count when it is at most `cap`, or `cap + 1` —
|
|
1619
|
+
* a sentinel, not a precise count past that point — once the pattern is
|
|
1620
|
+
* certain to exceed `cap`.
|
|
1621
|
+
*/
|
|
1622
|
+
export function braceExpansionCount(pattern, cap) {
|
|
1623
|
+
const limit = cap + 1;
|
|
1624
|
+
/** @type {{alternatives: number, branchProduct: number, start: number}[]} */
|
|
1625
|
+
const frames = [{ alternatives: 0, branchProduct: 1, start: 0 }];
|
|
1626
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
1627
|
+
const nested = frames.length > 1;
|
|
1628
|
+
const top = frames[frames.length - 1];
|
|
1629
|
+
const ch = pattern[i];
|
|
1630
|
+
if (nested && ch === "}") {
|
|
1631
|
+
const range = top.alternatives === 0 ? rangeCardinality(pattern.slice(top.start, i)) : null;
|
|
1632
|
+
const contribution = range === null ? top.branchProduct : Math.min(range, limit);
|
|
1633
|
+
top.alternatives += contribution;
|
|
1634
|
+
frames.pop();
|
|
1635
|
+
const parent = frames[frames.length - 1];
|
|
1636
|
+
parent.branchProduct = Math.min(parent.branchProduct * top.alternatives, limit);
|
|
1637
|
+
} else if (nested && ch === ",") {
|
|
1638
|
+
top.alternatives += top.branchProduct;
|
|
1639
|
+
top.branchProduct = 1;
|
|
1640
|
+
} else if (ch === "{") {
|
|
1641
|
+
frames.push({ alternatives: 0, branchProduct: 1, start: i + 1 });
|
|
1642
|
+
}
|
|
1643
|
+
const current = frames[frames.length - 1];
|
|
1644
|
+
if (current.alternatives + current.branchProduct > cap) return limit;
|
|
1645
|
+
}
|
|
1646
|
+
// An unbalanced '{' leaves frames still open — close each one as though the
|
|
1647
|
+
// pattern had ended right there, folding its count into its parent. Never a
|
|
1648
|
+
// range: a range that reached end-of-string with no closing '}' is not
|
|
1649
|
+
// valid range syntax either, so it falls back to the same "one alternative"
|
|
1650
|
+
// treatment an unbalanced literal group gets.
|
|
1651
|
+
while (frames.length > 1) {
|
|
1652
|
+
const frame = frames.pop();
|
|
1653
|
+
frame.alternatives += frame.branchProduct;
|
|
1654
|
+
const parent = frames[frames.length - 1];
|
|
1655
|
+
parent.branchProduct = Math.min(parent.branchProduct * frame.alternatives, limit);
|
|
1656
|
+
if (parent.alternatives + parent.branchProduct > cap) return limit;
|
|
1657
|
+
}
|
|
1658
|
+
const root = frames[0];
|
|
1659
|
+
return Math.min(root.alternatives + root.branchProduct, limit);
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Why `pattern` cannot be handed to `path.posix.matchesGlob`, or `null`.
|
|
1664
|
+
*
|
|
1665
|
+
* `path.posix.matchesGlob`'s brace-group support expands combinatorially
|
|
1666
|
+
* (`braceExpansionCount`'s own doc comment carries the measurement), and
|
|
1667
|
+
* `boundarySuppressions[].path`, `coverage.exempt[].path`,
|
|
1668
|
+
* `projectRules[].match` and `projects.infer.include`/`exclude` are all
|
|
1669
|
+
* matched with it while validating only that the string is non-empty. Every
|
|
1670
|
+
* one of those four fields is attacker-controlled the moment a pull request
|
|
1671
|
+
* edits `archkeep.json` or the boundary config (`../../../../SECURITY.md`), so a
|
|
1672
|
+
* crafted pattern reaching the real matcher unchecked is a denial of
|
|
1673
|
+
* service — refusing it here, loudly, at config load is both the security
|
|
1674
|
+
* fix and the "empty result is a claim, not a shrug" fix
|
|
1675
|
+
* (`../../../../AGENTS.md`): a config that fails to validate must never be
|
|
1676
|
+
* silently treated as "no suppressions/exemptions/rules declared."
|
|
1677
|
+
*
|
|
1678
|
+
* @param {string} pattern
|
|
1679
|
+
* @returns {string|null}
|
|
1680
|
+
*/
|
|
1681
|
+
export function globComplexityError(pattern) {
|
|
1682
|
+
if (braceExpansionCount(pattern, MAX_GLOB_EXPANSIONS) <= MAX_GLOB_EXPANSIONS) return null;
|
|
1683
|
+
return (
|
|
1684
|
+
`expands to more than ${MAX_GLOB_EXPANSIONS} brace-driven alternatives for ` +
|
|
1685
|
+
`'path.posix.matchesGlob' to match without the combinatorial cost this engine refuses to ` +
|
|
1686
|
+
`pay — narrow the brace groups`
|
|
1687
|
+
);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/**
|
|
1691
|
+
* `path.posix.matchesGlob`, guarded by `globComplexityError` first — the one
|
|
1692
|
+
* place `boundarySuppressions` (`../config.mjs`'s `suppressionCovers`),
|
|
1693
|
+
* `coverage.exempt`, `projectRules` and `projects.infer.include`/`exclude`
|
|
1694
|
+
* (`../providers/native/model.mjs`'s `matchesGlob` export) reach the real
|
|
1695
|
+
* matcher, so a pattern that slipped past config-load validation — or a
|
|
1696
|
+
* future caller that never validates — still cannot reach the expensive call
|
|
1697
|
+
* uncounted.
|
|
1698
|
+
*
|
|
1699
|
+
* @param {string} path
|
|
1700
|
+
* @param {string} pattern
|
|
1701
|
+
* @returns {boolean}
|
|
1702
|
+
* @throws {Error} when `pattern` fails `globComplexityError`.
|
|
1703
|
+
*/
|
|
1704
|
+
export function safeMatchesGlob(path, pattern) {
|
|
1705
|
+
const problem = globComplexityError(pattern);
|
|
1706
|
+
if (problem) throw new Error(`archkeep: glob pattern '${pattern}' ${problem}`);
|
|
1707
|
+
return posix.matchesGlob(path, pattern);
|
|
1708
|
+
}
|