@ecoma-io/archkeep 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- package/src/workspace.mjs +580 -0
|
@@ -0,0 +1,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
|
+
}
|