@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,640 @@
1
+ /**
2
+ * Position-preserving masker: returns a string the same length as `src` where
3
+ * every non-code region (comments, string literals, template literals, regex
4
+ * literals) is replaced with spaces and every code character is kept in place.
5
+ * Newlines survive the blanking, so line N of the result is line N of `src`.
6
+ *
7
+ * Same length is the contract: `match.index` in the result is the byte offset
8
+ * in the original `src`, so a guard can map a hit back to the exact `file:line`
9
+ * in the source. The guard exists to name the site of a violation; a
10
+ * position-preserving mask is what makes that naming exact.
11
+ *
12
+ * **The two ways this can be wrong are not equal, and every decision below is
13
+ * settled by that.** Masking a region that was really code hides whatever it
14
+ * held from every guard that scans the result, and a hidden `Date.now()` is
15
+ * byte-for-byte indistinguishable from a module that never had one — the
16
+ * silent direction `../../../../AGENTS.md` is written against. Leaving a
17
+ * region unmasked that was really a literal costs at most a spurious hit,
18
+ * which someone reads and disputes. So where this scanner cannot decide it
19
+ * declines to mask: **every scan below refuses rather than masking when it
20
+ * cannot find its own close**, and refusing leaves the opening character
21
+ * standing as ordinary code. Two of the four constructs cannot cross an
22
+ * unescaped newline (a string literal, a regex literal), so the newline is
23
+ * their bound; the other two legally can (a template literal, a block
24
+ * comment), so end of input is theirs and reaching it means the lexer lost
25
+ * sync rather than that the construct was long. A `//` comment is the one
26
+ * region with no close to miss — end of line and end of input both legitimately
27
+ * end it — so it is the one scan with nothing to refuse. A scan that masked to end of input on losing sync
28
+ * would blank the rest of the file — the silent direction, and measured: a
29
+ * regex literal holding a backtick, left unmasked because `)` was read as an
30
+ * operand, put that backtick in code position, and the template scan it opened
31
+ * ran to end of input and blanked every line after it.
32
+ *
33
+ * Deliberately a small hand-written lexer rather than a parser: it is a gate
34
+ * on raw `Date.now()`/`Math.random()`/`new Date()` and `localeCompare`, and
35
+ * for that it only needs the classifier "is this position inside a
36
+ * comment/string/template/regex". The one thing it does need a real answer to
37
+ * is regex-versus-division, because that is the single decision where being
38
+ * wrong is silent — which is why `scanCodeToken` and the `prev` token class
39
+ * below exist instead of the previous-character test they replace.
40
+ *
41
+ * The measured failure that shape could not survive: the old branch keyed the
42
+ * decision off the last non-space CHARACTER, so the `/` in
43
+ * `src/workspace.mjs`'s `return /('|")?exposes('|")?:/.test(config);` was
44
+ * judged against the `n` of `return` and read as division. The regex was left
45
+ * unmasked, the apostrophe inside it opened the string branch — which had no
46
+ * newline stop — and everything from there to end of file was blanked: 83.5%
47
+ * of that module's non-space characters, with the masked result coming out one
48
+ * byte LONGER than its source on top of it. Every wall-clock read in that tail
49
+ * scanned clean.
50
+ *
51
+ * These are the shapes the seam must recognize and their failure modes, each
52
+ * pinned by `mask-non-code.test.mjs` and by `intent.test.mjs`'s Contract K
53
+ * guard running against the shipped tree it is a part of:
54
+ *
55
+ * - A `//` inside a string (e.g. `"https://"`) is never misread as a comment
56
+ * because the string branch runs first and consumes the whole literal.
57
+ * - A `'` or `"` inside a template expression is skipped by the template
58
+ * branch's own intra-`${}` scan, which tracks nested strings, templates,
59
+ * comments, and brace depth, so a `}` inside a string (e.g. `${ map["}"] }`)
60
+ * or inside an object literal (`${ {a: 1} }`) never ends the interpolation
61
+ * early.
62
+ * - A division `/` after a `]`, an identifier, a numeric literal or a
63
+ * `++`/`--` is not a regex, and a `/` after an operator, a `(`, a `,`, a
64
+ * `{`/`}`, a `;` or one of the keywords that can only be followed by an
65
+ * expression (`return`, `typeof`, `case`, `throw`, …) is. That is the whole
66
+ * of the regex/division rule, and it reads the previous TOKEN rather than
67
+ * the previous character.
68
+ * - A keyword is only a keyword in KEYWORD POSITION. Every word in the set
69
+ * below is also a legal property name — `mod.default`, `Array.of`,
70
+ * `obj.in` — so `mod.default / 2` is a division. The rule is general
71
+ * rather than a list of the ones that turned up:
72
+ * a name directly after `.` or `?.` is a PROPERTY and carries no keyword
73
+ * identity at all, which is also why a private name (`this.#default`) is
74
+ * scanned as one token. Reading `mod.default` as the keyword put the `/`
75
+ * after it in operator position, and the regex scan that opened blanked
76
+ * `Date.now()` out of `const q = mod.default / 2 + Date.now() / 3;`.
77
+ * - A `)` is classified by the `(` it closes, not by being a `)`. A paren
78
+ * opened directly after `if`, `while`, `for`, `switch`, `catch` or `with`
79
+ * IN KEYWORD POSITION heads a control structure, and what follows its `)`
80
+ * is a statement — where a `/` can only be a regex, since no statement may
81
+ * begin with a division. Every other `)` ends a value, so a `/` after it
82
+ * divides. The stack that answers this is one boolean per open paren, and
83
+ * it is the whole reason `if (x) /re/.test(y)` no longer leaves a regex in
84
+ * code position (`a.if(x) / 2` still divides: property, not keyword).
85
+ *
86
+ * Regex literals are also masked so a pattern that spells out `Date.now()` in
87
+ * source does not trip the wall-clock guard it documents.
88
+ *
89
+ * Three residual limits, stated so a reader can tell a limit from a bug. The
90
+ * first two can only over-report; the third cannot, and is called out as the
91
+ * one place this file still answers in the silent direction:
92
+ *
93
+ * - `}` is read as an operator, so a division right after an object literal
94
+ * (`({a: 1} / 2)`) would start a regex scan. That scan stops at the end of
95
+ * the line unless a second `/` appears on it, so the blast radius is one
96
+ * line rather than the rest of the file. Deciding `}` needs to know whether
97
+ * the brace closed a block or a value, which needs a parser — the paren
98
+ * stack above is affordable only because a control head sits one token in
99
+ * front of its `(`, and a `{` has no such marker.
100
+ * - A block comment that never closes is not masked at all: the scan refuses,
101
+ * the `/` falls back to the code arm, and the comment's prose is read as
102
+ * code from there. It can only over-report, and it is unreachable from
103
+ * valid JavaScript, where an opened block comment always closes.
104
+ * - **A template literal is masked whole, its interpolations included.** A
105
+ * `${Date.now()}` inside one is real code, blanked, and invisible to the
106
+ * guard that scans this result — the silent direction. This is a limit of
107
+ * the region model rather than of the lexer: `scanTemplate` already walks
108
+ * every interpolation and knows where each begins and ends, so closing it
109
+ * means having that scan report the text runs it masks instead of the one
110
+ * span from backtick to backtick that `maskNonCode` masks today. Measured
111
+ * on the shipped tree: no production module spells a forbidden read inside
112
+ * an interpolation, so no guard verdict turns on it right now — which is
113
+ * precisely how it would rot unnoticed.
114
+ */
115
+
116
+ /**
117
+ * The class of the previous significant token — the only thing the
118
+ * regex/division decision turns on.
119
+ *
120
+ * `operand` is anything a value can end with (identifier, keyword that is a
121
+ * value, numeric literal, string, template, regex, a call's `)`, `]`, `++`,
122
+ * `--`): a `/` after one of those divides. `operator` is everything else,
123
+ * including program start: a `/` there opens a regex literal. `member` is the
124
+ * `.` of a member access, an operator that additionally tells the NEXT token
125
+ * it is a property name rather than whatever keyword it may spell — the whole
126
+ * of the keyword-position rule, and the reason this is three values and not a
127
+ * boolean.
128
+ *
129
+ * @typedef {"operand" | "operator" | "member"} TokenKind
130
+ */
131
+
132
+ /**
133
+ * The lexer state the regex/division decision reads, threaded through one
134
+ * token scan at a time.
135
+ *
136
+ * `keyword` is the KEYWORD IDENTITY of the last significant token: its text
137
+ * when it was an identifier in keyword position, and `""` for everything else
138
+ * — punctuation, numbers, masked literals, and a name after `.`, which is a
139
+ * property and spells no keyword. Only `(` reads it, and only to ask whether
140
+ * the paren it is opening heads a control structure.
141
+ *
142
+ * `parens` carries one boolean per open paren, `true` when that paren was a
143
+ * control structure's head. `)` pops it to decide whether it ended a value or
144
+ * a condition. A pop on an empty stack answers `false` (operand), which is the
145
+ * conservative half: it can only leave a regex unmasked.
146
+ *
147
+ * @typedef {{ prev: TokenKind, keyword: string, parens: boolean[] }} CodeState
148
+ */
149
+
150
+ /**
151
+ * @returns {CodeState} Program start, which is operator position: a file whose
152
+ * first token is a regex literal masks it.
153
+ */
154
+ function createCodeState() {
155
+ return { prev: "operator", keyword: "", parens: [] };
156
+ }
157
+
158
+ /**
159
+ * Records that a complete VALUE was just consumed — a string, template or
160
+ * regex literal the caller masked, or a nested one a sub-scan skipped. A `/`
161
+ * after any of those divides, and none of them carries a keyword identity.
162
+ *
163
+ * @param {CodeState} state Mutated in place.
164
+ * @returns {void}
165
+ */
166
+ function markOperand(state) {
167
+ state.prev = "operand";
168
+ state.keyword = "";
169
+ }
170
+
171
+ /**
172
+ * Keywords after which a `/` can only begin a regex literal, because each one
173
+ * must be followed by an expression and none of them is a value itself.
174
+ *
175
+ * `this`, `super`, `true`, `false` and `null` are deliberately absent: they
176
+ * have identifier shape and ARE values, so `/` after them is division.
177
+ */
178
+ const EXPRESSION_KEYWORDS = new Set([
179
+ "return",
180
+ "typeof",
181
+ "case",
182
+ "in",
183
+ "of",
184
+ "delete",
185
+ "void",
186
+ "instanceof",
187
+ "new",
188
+ "do",
189
+ "else",
190
+ "yield",
191
+ "await",
192
+ "throw",
193
+ "default",
194
+ ]);
195
+
196
+ /**
197
+ * Keywords whose parenthesized head belongs to a CONTROL STRUCTURE rather than
198
+ * to a call, so that what follows the closing `)` is a statement.
199
+ *
200
+ * A statement cannot begin with a division — `/` there can only open a regex
201
+ * literal — which is what makes this decidable without a parser: the whole
202
+ * question is whether the `(` was preceded by one of these six in keyword
203
+ * position, and that is one token of lookbehind rather than a grammar.
204
+ * `switch`, `catch` and `with` can only be followed by `{`, so they change no
205
+ * verdict on their own; they are listed because leaving them out would make
206
+ * the set a list of the cases that came up rather than the rule.
207
+ */
208
+ const CONTROL_HEADS = new Set(["if", "while", "for", "switch", "catch", "with"]);
209
+
210
+ /**
211
+ * Character classes by code unit rather than by `RegExp.test` per character.
212
+ *
213
+ * This masker is run over every production module in the tree on every
214
+ * invocation of the determinism guard, so the per-character classification is
215
+ * the whole cost of the scan; a regex call per character puts it in seconds
216
+ * where these put it in milliseconds. `code > 127` counts as identifier
217
+ * material because the main loop tests whitespace FIRST — anything non-ASCII
218
+ * left over is part of a name, and ending an identifier run early would put a
219
+ * following `/` in operator position and mask a division.
220
+ *
221
+ * @param {number} code
222
+ * @returns {boolean}
223
+ */
224
+ function isSpaceCode(code) {
225
+ return code === 32 || (code >= 9 && code <= 13) || code === 0xa0 || code === 0xfeff;
226
+ }
227
+
228
+ /**
229
+ * @param {number} code
230
+ * @returns {boolean}
231
+ */
232
+ function isIdentifierStart(code) {
233
+ return (
234
+ (code >= 97 && code <= 122) ||
235
+ (code >= 65 && code <= 90) ||
236
+ code === 95 ||
237
+ code === 36 ||
238
+ code > 127
239
+ );
240
+ }
241
+
242
+ /**
243
+ * @param {number} code
244
+ * @returns {boolean}
245
+ */
246
+ function isDigit(code) {
247
+ return code >= 48 && code <= 57;
248
+ }
249
+
250
+ /**
251
+ * Replaces every character with a space, except newlines, which are kept so
252
+ * the masked result keeps the source's line structure. Indexed by code UNIT
253
+ * rather than iterated with `for…of`, which walks code POINTS and would
254
+ * collapse a surrogate pair into a single space — one byte short, and the
255
+ * same-length contract broken by a literal containing an emoji.
256
+ *
257
+ * @param {string} text
258
+ * @returns {string}
259
+ */
260
+ function blank(text) {
261
+ return text.replace(/[^\n]/g, " ");
262
+ }
263
+
264
+ /**
265
+ * Scans a single- or double-quoted string starting at its opening quote.
266
+ *
267
+ * A JavaScript string literal cannot contain a RAW newline — only an escaped
268
+ * one (a `\` immediately before it, the line continuation), which the escape
269
+ * skip below consumes. So an unescaped newline before the closing quote means
270
+ * this was never a string literal, and the scan refuses rather than masking to
271
+ * end of file. That refusal is the second line of defence behind the token
272
+ * classifier: the measured failure in this file's header needed BOTH a regex
273
+ * read as division AND a string scan with nothing to stop it, and this is the
274
+ * half that caps the damage of any future misread at a single line.
275
+ *
276
+ * @param {string} src
277
+ * @param {number} i Index of the opening quote.
278
+ * @returns {number} Index just past the closing quote, or `-1` when the
279
+ * literal does not close on its own line.
280
+ */
281
+ function scanQuoted(src, i) {
282
+ const quote = src[i];
283
+ let j = i + 1;
284
+ while (j < src.length) {
285
+ const c = src[j];
286
+ if (c === "\\") {
287
+ j += 2;
288
+ continue;
289
+ }
290
+ if (c === "\n") return -1;
291
+ if (c === quote) return j + 1;
292
+ j++;
293
+ }
294
+ return -1;
295
+ }
296
+
297
+ /**
298
+ * Scans a regex literal starting at its opening `/`, flags included.
299
+ *
300
+ * A character class may hold an unescaped `/` (`/[/]/`), which is why `inClass`
301
+ * exists; a regex literal may not hold an unescaped newline, which is why the
302
+ * scan gives up at one rather than running to end of file. Giving up means the
303
+ * caller keeps the `/` as code — the loud direction.
304
+ *
305
+ * @param {string} src
306
+ * @param {number} i Index of the opening `/`.
307
+ * @returns {number} Index just past the closing `/` and its flags, or `-1`
308
+ * when the literal does not close on its own line.
309
+ */
310
+ function scanRegex(src, i) {
311
+ let j = i + 1;
312
+ let inClass = false;
313
+ while (j < src.length) {
314
+ const c = src[j];
315
+ if (c === "\\") {
316
+ j += 2;
317
+ continue;
318
+ }
319
+ if (c === "\n") return -1;
320
+ if (inClass) {
321
+ if (c === "]") inClass = false;
322
+ } else if (c === "[") {
323
+ inClass = true;
324
+ } else if (c === "/") {
325
+ let end = j + 1;
326
+ while (end < src.length && /[dgimsuvy]/.test(src[end])) end++;
327
+ return end;
328
+ }
329
+ j++;
330
+ }
331
+ return -1;
332
+ }
333
+
334
+ /**
335
+ * Consumes the one CODE token starting at `src[i]`, advancing `state` to what
336
+ * a following `/` should be read as. Callers consume comments, strings,
337
+ * templates and regex literals before reaching here, so this only ever sees
338
+ * identifiers, private names, numeric literals and punctuation.
339
+ *
340
+ * @param {string} src
341
+ * @param {number} i Index of the token's first character.
342
+ * @param {CodeState} state Mutated in place.
343
+ * @returns {number} Index just past the token.
344
+ */
345
+ function scanCodeToken(src, i, state) {
346
+ const c = src[i];
347
+ const code = src.charCodeAt(i);
348
+ // Read the incoming keyword identity before any arm below overwrites it: the
349
+ // `(` arm is asking about the token BEFORE it, not about itself.
350
+ const previousKeyword = state.keyword;
351
+ // A private name is one token (`#count`), and never a keyword: `#default` is
352
+ // a legal field name, because a private name is `#` + any IdentifierName.
353
+ const isPrivateName = c === "#" && isIdentifierStart(src.charCodeAt(i + 1));
354
+ if (isPrivateName || isIdentifierStart(code)) {
355
+ let j = i + 1;
356
+ while (j < src.length) {
357
+ const next = src.charCodeAt(j);
358
+ if (!isIdentifierStart(next) && !isDigit(next)) break;
359
+ j++;
360
+ }
361
+ // Keyword position, and the general rule the header argues: a name reached
362
+ // through `.` or `?.` is a PROPERTY, so it spells no keyword at all —
363
+ // `mod.default`, `Array.of`, `obj.in`, `a?.default` are names, and the `/`
364
+ // after one of them divides.
365
+ const keyword = state.prev === "member" || isPrivateName ? "" : src.slice(i, j);
366
+ state.keyword = keyword;
367
+ state.prev = EXPRESSION_KEYWORDS.has(keyword) ? "operator" : "operand";
368
+ return j;
369
+ }
370
+ state.keyword = "";
371
+ if (isDigit(code)) {
372
+ // One run covers every numeric spelling that matters here — `0x1f`, `1e5`,
373
+ // `1_000`, `1.5`, `10n` — because the only question asked of it is where
374
+ // the literal ends, never what it is worth.
375
+ let j = i + 1;
376
+ while (j < src.length) {
377
+ const next = src.charCodeAt(j);
378
+ if (!isIdentifierStart(next) && !isDigit(next) && next !== 46) break;
379
+ j++;
380
+ }
381
+ state.prev = "operand";
382
+ return j;
383
+ }
384
+ if (c === ".") {
385
+ // The `.` of a member access — and of `?.`, whose `?` is scanned as its
386
+ // own operator token just before it. `...` reaches here three times, which
387
+ // makes the name after a spread a property too: that only ever turns a
388
+ // keyword into an operand, and an operand can only leave a `/` unmasked.
389
+ state.prev = "member";
390
+ return i + 1;
391
+ }
392
+ if (c === "(") {
393
+ state.parens.push(CONTROL_HEADS.has(previousKeyword));
394
+ state.prev = "operator";
395
+ return i + 1;
396
+ }
397
+ if (c === ")") {
398
+ state.prev = state.parens.pop() === true ? "operator" : "operand";
399
+ return i + 1;
400
+ }
401
+ if (c === "]") {
402
+ state.prev = "operand";
403
+ return i + 1;
404
+ }
405
+ if ((c === "+" || c === "-") && src[i + 1] === c) {
406
+ state.prev = "operand";
407
+ return i + 2;
408
+ }
409
+ state.prev = "operator";
410
+ return i + 1;
411
+ }
412
+
413
+ /**
414
+ * Scans a template literal starting just after its opening backtick and
415
+ * returns the index of that opening backtick's matching close.
416
+ *
417
+ * `${` opens an interpolation expression; inside one, `}` closes a brace level
418
+ * only while one is open, and a `{` opened inside the expression (an object
419
+ * literal, a block-bodied arrow) opens one of its own, so `${ {a: 1} }` no
420
+ * longer ends at the object's closing brace. A `}` inside a nested string,
421
+ * comment or template is consumed by that sub-scan and never counts at all.
422
+ * Nested template literals inside an interpolation are scanned by this same
423
+ * function recursively. The backtick that closes the literal is found only at
424
+ * depth zero.
425
+ *
426
+ * Mirrors the region set `maskNonCode` recognizes — including the same
427
+ * previous-token regex/division rule, so a `${ x.replace(/'/g, "") }` inside a
428
+ * template does not end the interpolation somewhere a reader cannot see. It
429
+ * differs only in producing an index rather than masked text.
430
+ *
431
+ * A template literal legally spans newlines, so the newline that bounds
432
+ * `scanQuoted` and `scanRegex` is not available here: end of input is the only
433
+ * bound, and reaching it means this scan never found a close. It then DECLINES
434
+ * — the same answer those two give — because masking to end of input is the
435
+ * silent direction, and a backtick that opened nothing is exactly the shape a
436
+ * lost-sync lexer produces. The caller keeps the backtick as code, so the cost
437
+ * of a misread here is nothing blanked at all.
438
+ *
439
+ * @param {string} src Source text.
440
+ * @param {number} i Index just past the opening backtick.
441
+ * @returns {number} Index of the closing backtick, or `-1` when the literal
442
+ * never closes.
443
+ */
444
+ function scanTemplate(src, i) {
445
+ let j = i;
446
+ let depth = 0;
447
+ const state = createCodeState();
448
+ while (j < src.length) {
449
+ const c = src[j];
450
+ if (depth === 0) {
451
+ // Template text: only an escape, an interpolation, and the closing
452
+ // backtick mean anything.
453
+ if (c === "\\") {
454
+ j += 2;
455
+ continue;
456
+ }
457
+ if (c === "$" && src[j + 1] === "{") {
458
+ depth++;
459
+ state.prev = "operator";
460
+ state.keyword = "";
461
+ j += 2;
462
+ continue;
463
+ }
464
+ if (c === "`") return j;
465
+ j++;
466
+ continue;
467
+ }
468
+ // Inside an interpolation expression.
469
+ if (c === "/" && src[j + 1] === "/") {
470
+ const nl = src.indexOf("\n", j);
471
+ // A line comment running to end of input inside an interpolation is a
472
+ // template that never closes — the same verdict as falling out of the
473
+ // loop below, reached one step earlier.
474
+ if (nl === -1) return -1;
475
+ j = nl + 1;
476
+ continue;
477
+ }
478
+ if (c === "/" && src[j + 1] === "*") {
479
+ const end = src.indexOf("*/", j + 2);
480
+ if (end === -1) return -1;
481
+ j = end + 2;
482
+ continue;
483
+ }
484
+ if (c === "`") {
485
+ // Nested template: it closes at its own depth-zero backtick. A nested
486
+ // one that never closes means this one cannot close either — there is no
487
+ // backtick left in the file for it — so the refusal propagates outward
488
+ // rather than being turned into an index by a `+ 1` on `-1`.
489
+ const close = scanTemplate(src, j + 1);
490
+ if (close === -1) return -1;
491
+ j = close + 1;
492
+ markOperand(state);
493
+ continue;
494
+ }
495
+ if (c === "'" || c === '"') {
496
+ const end = scanQuoted(src, j);
497
+ if (end !== -1) {
498
+ j = end;
499
+ markOperand(state);
500
+ continue;
501
+ }
502
+ }
503
+ if (c === "/" && state.prev !== "operand") {
504
+ const end = scanRegex(src, j);
505
+ if (end !== -1) {
506
+ j = end;
507
+ markOperand(state);
508
+ continue;
509
+ }
510
+ }
511
+ if (c === "{") {
512
+ depth++;
513
+ state.prev = "operator";
514
+ state.keyword = "";
515
+ j++;
516
+ continue;
517
+ }
518
+ if (c === "}") {
519
+ depth--;
520
+ markOperand(state);
521
+ j++;
522
+ continue;
523
+ }
524
+ if (isSpaceCode(src.charCodeAt(j))) {
525
+ j++;
526
+ continue;
527
+ }
528
+ j = scanCodeToken(src, j, state);
529
+ }
530
+ return -1;
531
+ }
532
+
533
+ /**
534
+ * @param {string} src Source text.
535
+ * @returns {string} `src` with every non-code region blanked, same length,
536
+ * same line structure.
537
+ */
538
+ export function maskNonCode(src) {
539
+ /**
540
+ * Chunks of the result, joined once at the end. Code is copied through in
541
+ * RUNS rather than a token at a time — `plainFrom` is where the current
542
+ * unmasked run began, and only a masked region flushes it.
543
+ * @type {string[]}
544
+ */
545
+ const parts = [];
546
+ let plainFrom = 0;
547
+ let i = 0;
548
+ /** The lexer state the regex/division decision reads. */
549
+ const state = createCodeState();
550
+ /**
551
+ * Blanks `src[from…to)` into the result, flushing whatever code preceded it.
552
+ * @param {number} from
553
+ * @param {number} to
554
+ */
555
+ const mask = (from, to) => {
556
+ if (plainFrom < from) parts.push(src.slice(plainFrom, from));
557
+ parts.push(blank(src.slice(from, to)));
558
+ plainFrom = to;
559
+ };
560
+ while (i < src.length) {
561
+ const c = src[i];
562
+ // Single-line comment, blanked up to but NOT including its newline: the
563
+ // newline is copied through as code, which is what keeps the masked result
564
+ // the same length as the source AND on the same lines. Dropping it would
565
+ // move every `match.index` after the comment one byte early, and a
566
+ // consumer computing a line number from that offset would land on the
567
+ // wrong line (the determinism guard's regression: a `Date.now()` right
568
+ // after an allow-listed line silently inheriting that line's exemption).
569
+ //
570
+ // `prev` is deliberately left alone here and in the block-comment arm: a
571
+ // comment is not a token, so `return // why\n/re/.test(x)` still sees
572
+ // `return` and still masks the regex.
573
+ if (c === "/" && src[i + 1] === "/") {
574
+ const nl = src.indexOf("\n", i);
575
+ const to = nl === -1 ? src.length : nl;
576
+ mask(i, to);
577
+ i = to;
578
+ continue;
579
+ }
580
+ // Block comment. A block comment legally spans newlines, so like the
581
+ // template below it has no bound short of end of input — and one that
582
+ // never closes is not a comment this scanner can trust, so it refuses
583
+ // instead of blanking the rest of the file. The `/` then falls through to
584
+ // the code arm and the prose after it is read as code, which can only
585
+ // over-report.
586
+ if (c === "/" && src[i + 1] === "*") {
587
+ const end = src.indexOf("*/", i + 2);
588
+ if (end !== -1) {
589
+ mask(i, end + 2);
590
+ i = end + 2;
591
+ continue;
592
+ }
593
+ }
594
+ // String literal. An opening quote with no closer on its line was not a
595
+ // string at all, so the character falls through to the code arm and the
596
+ // rest of the line stays visible to whatever scans this.
597
+ if (c === "'" || c === '"') {
598
+ const end = scanQuoted(src, i);
599
+ if (end !== -1) {
600
+ mask(i, end);
601
+ i = end;
602
+ markOperand(state);
603
+ continue;
604
+ }
605
+ }
606
+ // Template literal. `${` opens an interpolation and `}` closes a brace
607
+ // level; see `scanTemplate` for what it tracks in between, and for why a
608
+ // literal that never closes is declined here rather than masked to end of
609
+ // input. Declining drops the backtick to the code arm below.
610
+ if (c === "`") {
611
+ const close = scanTemplate(src, i + 1);
612
+ if (close !== -1) {
613
+ mask(i, close + 1);
614
+ i = close + 1;
615
+ markOperand(state);
616
+ continue;
617
+ }
618
+ }
619
+ // Regex literal — only outside operand position, and only when it closes
620
+ // on its own line. Both refusals leave the `/` as code.
621
+ if (c === "/" && state.prev !== "operand") {
622
+ const end = scanRegex(src, i);
623
+ if (end !== -1) {
624
+ mask(i, end);
625
+ i = end;
626
+ markOperand(state);
627
+ continue;
628
+ }
629
+ }
630
+ // Whitespace is not a token: it is copied through and leaves `prev` alone,
631
+ // so `= /re/` and `=/re/` reach the same verdict.
632
+ if (isSpaceCode(src.charCodeAt(i))) {
633
+ i++;
634
+ continue;
635
+ }
636
+ i = scanCodeToken(src, i, state);
637
+ }
638
+ parts.push(src.slice(plainFrom));
639
+ return parts.join("");
640
+ }