@tsslint/config 3.1.0 → 3.1.2

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 (2) hide show
  1. package/lib/plugins/ignore.js +189 -32
  2. package/package.json +5 -6
@@ -1,38 +1,181 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.create = create;
4
- const ts_api_utils_1 = require("ts-api-utils");
5
- // `forEachComment` walks every token of the source file; if multiple ignore
6
- // plugins are configured (one per directive form) we'd walk the same file
7
- // 3+ times per lint pass. Cache the comment list once per ts.SourceFile so
8
- // every plugin shares a single sweep. The WeakMap drops entries when the
9
- // SourceFile is GC'd.
4
+ // Inlined comment-trivia walker. Equivalent to ts-api-utils'
5
+ // `forEachComment` but specialized for our needs (no `kind` / `value`
6
+ // fields, no generator overhead, no per-token closure for the
7
+ // trivia-collector). Algorithm matches upstream:
8
+ //
9
+ // 1. Iterative DFS over `node.getChildren(sourceFile)` — token-only
10
+ // leaves drive the walk. Realizes the AST tree, but the lint
11
+ // pipeline does this anyway for type-aware rules so the cost is
12
+ // amortized across the whole pass.
13
+ // 2. For each token, scan `forEachLeadingCommentRange` (skip shebang
14
+ // at file start) and `forEachTrailingCommentRange`.
15
+ // 3. JSX needs special care: `JsxText` tokens can't carry leading
16
+ // trivia, and trailing trivia rules differ for `}` / `>` tokens
17
+ // based on their JSX-element context.
18
+ //
19
+ // Trivia regions sit BETWEEN tokens — they can't contain string,
20
+ // template, or regex literals (those belong to token text). That's
21
+ // why driving `ts.createScanner` directly without parser context is
22
+ // unsafe (regex / template `${}` interpolation get misclassified) and
23
+ // why the AST-driven walk stays correct.
24
+ //
25
+ // Multiple ignore plugins (one per directive form) share the result via
26
+ // a per-SourceFile WeakMap so the second / third callers in a pass pay
27
+ // one map lookup instead of another scan.
10
28
  const sharedFileComments = new WeakMap();
11
- function getFileComments(file) {
29
+ function getFileComments(ts, file) {
12
30
  let comments = sharedFileComments.get(file);
13
31
  if (!comments) {
14
32
  comments = [];
15
- (0, ts_api_utils_1.forEachComment)(file, (fullText, { pos, end }) => {
16
- const start = pos + 2; // strip leading `//` or `/*`
17
- comments.push({
33
+ const fullText = file.text;
34
+ const SK = ts.SyntaxKind;
35
+ const notJsx = file.languageVariant !== ts.LanguageVariant.JSX;
36
+ // Iterative DFS: token leaves trigger trivia scans. Single shared
37
+ // callback object instead of allocating a closure per
38
+ // forEachLeading/Trailing call.
39
+ //
40
+ // CRITICAL: TS's `forEachLeading/TrailingCommentRange` stops
41
+ // iterating as soon as the callback returns a truthy value
42
+ // (matches the documented `forEachX` contract — return non-
43
+ // undefined to short-circuit). The callback body MUST NOT
44
+ // implicit-return `array.push(...)` (returns length → truthy →
45
+ // only the first comment per token gets collected).
46
+ const collected = comments;
47
+ const onComment = (pos, end) => {
48
+ const start = pos + 2; // strip `//` or `/*`
49
+ collected.push({
18
50
  pos: start,
19
51
  end,
20
52
  text: fullText.substring(start, end),
21
53
  });
22
- });
54
+ // no return → undefined → continue iteration
55
+ };
56
+ // `getChildren` array per visit creates the most allocation
57
+ // pressure; we still need it because `forEachChild` skips token
58
+ // kinds. Iterative stack avoids the recursive overhead.
59
+ let stack = [file];
60
+ while (stack.length > 0) {
61
+ const node = stack.pop();
62
+ if (ts.isTokenKind(node.kind)) {
63
+ if (node.pos === node.end) {
64
+ continue;
65
+ }
66
+ if (node.kind !== SK.JsxText) {
67
+ // Skip the shebang at file position 0; otherwise
68
+ // `forEachLeadingCommentRange` would re-emit it as a
69
+ // line comment.
70
+ const scanFrom = node.pos === 0
71
+ ? (ts.getShebang(fullText) ?? '').length
72
+ : node.pos;
73
+ ts.forEachLeadingCommentRange(fullText, scanFrom, onComment);
74
+ }
75
+ if (notJsx || canHaveTrailingTrivia(ts, node)) {
76
+ ts.forEachTrailingCommentRange(fullText, node.end, onComment);
77
+ }
78
+ continue;
79
+ }
80
+ const children = node.getChildren(file);
81
+ // Push in reverse so DFS order matches source order.
82
+ for (let i = children.length - 1; i >= 0; --i) {
83
+ stack.push(children[i]);
84
+ }
85
+ }
23
86
  sharedFileComments.set(file, comments);
24
87
  }
25
88
  return comments;
26
89
  }
90
+ function canHaveTrailingTrivia(ts, token) {
91
+ const SK = ts.SyntaxKind;
92
+ switch (token.kind) {
93
+ case SK.CloseBraceToken:
94
+ // `}` of a JsxExpression inside a JSX element: no trailing
95
+ // trivia (the next character is JsxText, not a comment).
96
+ return token.parent.kind !== SK.JsxExpression
97
+ || !isJsxElementOrFragment(ts, token.parent.parent);
98
+ case SK.GreaterThanToken:
99
+ switch (token.parent.kind) {
100
+ case SK.JsxClosingElement:
101
+ case SK.JsxClosingFragment:
102
+ return !isJsxElementOrFragment(ts, token.parent.parent.parent);
103
+ case SK.JsxOpeningElement:
104
+ // Type-args list keeps trailing trivia; the `>` that
105
+ // closes the opening tag itself does not (next is
106
+ // JsxText).
107
+ return token.end !== token.parent.end;
108
+ case SK.JsxOpeningFragment:
109
+ return false;
110
+ case SK.JsxSelfClosingElement:
111
+ return token.end !== token.parent.end
112
+ || !isJsxElementOrFragment(ts, token.parent.parent);
113
+ }
114
+ }
115
+ return true;
116
+ }
117
+ function isJsxElementOrFragment(ts, node) {
118
+ const SK = ts.SyntaxKind;
119
+ return node.kind === SK.JsxElement || node.kind === SK.JsxFragment;
120
+ }
27
121
  function create(cmdOption, reportsUnusedComments) {
28
122
  const mode = typeof cmdOption === 'string' ? 'singleLine' : 'multiLine';
29
123
  const [cmd, endCmd] = Array.isArray(cmdOption) ? cmdOption : [cmdOption, undefined];
30
124
  const cmdText = cmd.replace(/\?/g, '');
31
- const withRuleId = '[ \\t]*\\b(?<ruleId>\\w\\S*)?';
125
+ // Rule IDs in the wild: bare (`no-shadow`), plugin-prefixed
126
+ // (`regexp/no-foo`), or scoped (`@typescript-eslint/no-foo`,
127
+ // `@scope/plugin/rule`). The previous `\w\S*` required a word char
128
+ // first — `@` failed, so every scoped disable comment fell into the
129
+ // `comments.get(undefined)` "disable all" bucket. Stacking multiple
130
+ // scoped disables then collapsed them into duplicates, only the first
131
+ // got marked used by an incoming error, the rest were reported as
132
+ // unused-comment FPs (real Astro repro: stacked `@typescript-eslint/...`
133
+ // block disables on `extendables.ts`).
134
+ //
135
+ // Match: leading scope segment `@xxx/`, then a name segment that
136
+ // starts with letter/underscore (skip `--` description delimiter),
137
+ // allowing `\w / . -` thereafter.
138
+ const ruleIdPattern = '(?:@[\\w-]+\\/)?[A-Za-z_][\\w/.-]*';
139
+ // Capture EVERYTHING after the command word as `tail`. The previous
140
+ // `(?<ruleId>...)?([ \\t]+[^\\r\\n]*)?` form captured a single rule
141
+ // id and let the trailing-text group eat the rest — but `,` is not
142
+ // in `[ \\t]+`, so for `eslint-disable rule1, rule2` the optional
143
+ // ruleId group backtracked to nothing, the trailing-text group then
144
+ // consumed `rule1, rule2 */`, and the result was treated as a bare
145
+ // `eslint-disable` (= disable ALL). Now we capture the whole tail
146
+ // and split it ourselves below to extract the rule list.
147
+ //
148
+ // `cmd` MUST be followed by whitespace, `*/`, or end of comment —
149
+ // not by another word/`-` char. Otherwise `eslint-disable` would
150
+ // also match `eslint-disable-line ...` (treating `-line ...` as a
151
+ // malformed rule list and falling back to disable-all). The
152
+ // `(?:\\s+(?<tail>...))?` form requires whitespace if anything
153
+ // follows.
32
154
  const header = '^\\s*';
33
- const ending = '([ \\t]+[^\\r\\n]*)?$';
34
- const reg = new RegExp(`${header}${cmd}${withRuleId}${ending}`);
35
- const endReg = endCmd ? new RegExp(`${header}${endCmd}${withRuleId}${ending}`) : undefined;
155
+ const tail = '(?:\\s+(?<tail>[^]*?))?(?:\\*\\/)?\\s*$';
156
+ const reg = new RegExp(`${header}${cmd}${tail}`);
157
+ const endReg = endCmd ? new RegExp(`${header}${endCmd}${tail}`) : undefined;
158
+ // `tail` parsing: split by `--` (description marker), keep left side,
159
+ // split by `,`, trim each, drop blanks, validate against ruleIdPattern.
160
+ // Empty result = disable-all (matches ESLint semantics).
161
+ const ruleIdRegExp = new RegExp(`^${ruleIdPattern}$`);
162
+ function parseRuleList(rawTail) {
163
+ if (!rawTail)
164
+ return undefined; // bare command — disable all
165
+ const beforeDescription = rawTail.split(/\s--\s/)[0];
166
+ const parts = beforeDescription.split(',').map(s => s.trim()).filter(Boolean);
167
+ if (parts.length === 0)
168
+ return undefined; // disable all
169
+ const valid = parts.filter(p => ruleIdRegExp.test(p));
170
+ // If any part is malformed (e.g. trailing `*/` snuck in), be
171
+ // conservative and treat the whole comment as disable-all rather
172
+ // than silently dropping rules. ESLint surfaces an error here;
173
+ // for our purposes mirroring the legacy "disable all on parse
174
+ // failure" preserves behaviour for malformed comments.
175
+ if (valid.length !== parts.length)
176
+ return undefined;
177
+ return valid;
178
+ }
36
179
  const completeReg1 = /^\s*\/\/(\s*)([\S]*)?$/;
37
180
  const completeReg2 = new RegExp(`//\\s*${cmd}(\\S*)?$`);
38
181
  return ({ typescript: ts, languageService }) => {
@@ -135,15 +278,18 @@ function create(cmdOption, reportsUnusedComments) {
135
278
  return results;
136
279
  }
137
280
  const comments = new Map();
138
- for (const c of getFileComments(file)) {
281
+ for (const c of getFileComments(ts, file)) {
139
282
  const startComment = c.text.match(reg);
140
283
  if (startComment?.index !== undefined) {
141
284
  const index = startComment.index + c.pos;
142
- const ruleId = startComment.groups?.ruleId;
143
- if (!comments.has(ruleId)) {
144
- comments.set(ruleId, []);
145
- }
146
- const disabledLines = comments.get(ruleId);
285
+ const ruleList = parseRuleList(startComment.groups?.tail);
286
+ // `undefined` = bare command (disable all). Else
287
+ // register one entry PER rule so comma-separated
288
+ // disables don't collapse into the disable-all
289
+ // bucket (the bug: `eslint-disable a, b` used
290
+ // to silently disable everything because the old
291
+ // regex backtracked to ruleId=undefined).
292
+ const ruleKeys = ruleList ?? [undefined];
147
293
  const line = file.getLineAndCharacterOfPosition(index).line;
148
294
  let startLine = line;
149
295
  if (mode === 'singleLine') {
@@ -152,23 +298,34 @@ function create(cmdOption, reportsUnusedComments) {
152
298
  startLine = line + 1; // If the comment is at the start of the line, the error is in the next line
153
299
  }
154
300
  }
155
- disabledLines.push({
156
- commentRange: [
157
- index - 2,
158
- index + startComment[0].length,
159
- ],
160
- startLine,
161
- });
301
+ const commentRange = [
302
+ index - 2,
303
+ index + startComment[0].length,
304
+ ];
305
+ for (const ruleId of ruleKeys) {
306
+ if (!comments.has(ruleId)) {
307
+ comments.set(ruleId, []);
308
+ }
309
+ // Per-rule state object: a paired `eslint-enable
310
+ // rule1` mutates `.endLine` on the matching
311
+ // rule's last entry. Sharing one state across
312
+ // `[rule1, rule2]` would cause the enable to
313
+ // also close rule2's window.
314
+ comments.get(ruleId).push({ commentRange, startLine });
315
+ }
162
316
  }
163
317
  else if (endReg) {
164
318
  const endComment = c.text.match(endReg);
165
319
  if (endComment?.index !== undefined) {
166
320
  const index = endComment.index + c.pos;
167
321
  const prevLine = file.getLineAndCharacterOfPosition(index).line;
168
- const ruleId = endComment.groups?.ruleId;
169
- const disabledLines = comments.get(ruleId);
170
- if (disabledLines) {
171
- disabledLines[disabledLines.length - 1].endLine = prevLine;
322
+ const endRuleList = parseRuleList(endComment.groups?.tail);
323
+ const endRuleKeys = endRuleList ?? [undefined];
324
+ for (const ruleId of endRuleKeys) {
325
+ const disabledLines = comments.get(ruleId);
326
+ if (disabledLines) {
327
+ disabledLines[disabledLines.length - 1].endLine = prevLine;
328
+ }
172
329
  }
173
330
  }
174
331
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsslint/config",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
4
4
  "license": "MIT",
5
5
  "engines": {
6
6
  "node": ">=22.6.0"
@@ -18,12 +18,11 @@
18
18
  "directory": "packages/config"
19
19
  },
20
20
  "dependencies": {
21
- "@tsslint/types": "3.1.0",
22
- "minimatch": "^10.0.1",
23
- "ts-api-utils": "^2.0.0"
21
+ "@tsslint/types": "3.1.2",
22
+ "minimatch": "^10.0.1"
24
23
  },
25
24
  "devDependencies": {
26
- "@tsslint/compat-eslint": "3.1.0",
25
+ "@tsslint/compat-eslint": "3.1.2",
27
26
  "tslint": "^6.1.3"
28
27
  },
29
28
  "peerDependencies": {
@@ -38,5 +37,5 @@
38
37
  "optional": true
39
38
  }
40
39
  },
41
- "gitHead": "3c3cf1ef52e0379d5c5f48050c1a98c2627c5d52"
40
+ "gitHead": "e6fb08d18e9f4feb2b0191199cecce4bdf3c4a29"
42
41
  }