@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.
Files changed (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. 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
+ }