@ttsc/lint 0.19.3 → 0.20.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 (63) hide show
  1. package/README.md +1 -1
  2. package/lib/index.js +26 -4
  3. package/lib/index.js.map +1 -1
  4. package/lib/structures/format/ITtscLintFormat.d.ts +14 -10
  5. package/lib/structures/rules/ITtscLintRegexpRules.d.ts +24 -9
  6. package/lib/structures/rules/ITtscLintSecurityRules.d.ts +18 -0
  7. package/lib/structures/rules/ITtscLintSolidRules.d.ts +22 -2
  8. package/lib/structures/rules/ITtscLintStorybookRules.d.ts +21 -0
  9. package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.d.ts +10 -1
  10. package/lib/structures/rules/ITtscLintTypeScriptRules.d.ts +13 -2
  11. package/linthost/compile.go +9 -4
  12. package/linthost/config.go +18 -14
  13. package/linthost/contrib_adapter.go +57 -0
  14. package/linthost/dispatch.go +5 -1
  15. package/linthost/display_width.go +179 -41
  16. package/linthost/engine.go +120 -4
  17. package/linthost/flags_gen.go +4 -4
  18. package/linthost/hints.go +207 -0
  19. package/linthost/host.go +68 -0
  20. package/linthost/lsp.go +148 -29
  21. package/linthost/print_dispatch.go +24 -0
  22. package/linthost/print_nodes_array.go +88 -15
  23. package/linthost/print_nodes_call.go +38 -31
  24. package/linthost/print_nodes_control_flow.go +319 -0
  25. package/linthost/print_nodes_function.go +140 -13
  26. package/linthost/print_nodes_list.go +22 -10
  27. package/linthost/project_engine.go +18 -1
  28. package/linthost/project_rules.go +47 -0
  29. package/linthost/rule_docs.go +114 -0
  30. package/linthost/rules_boundaries.go +80 -2
  31. package/linthost/rules_format_bracket_spacing.go +18 -6
  32. package/linthost/rules_format_clause_join.go +11 -8
  33. package/linthost/rules_format_indent.go +155 -4
  34. package/linthost/rules_format_print_width.go +12 -35
  35. package/linthost/rules_format_quote_props.go +88 -27
  36. package/linthost/rules_format_statement_split.go +81 -0
  37. package/linthost/rules_format_trailing_comma.go +62 -94
  38. package/linthost/rules_gap.go +26 -17
  39. package/linthost/rules_jsdoc.go +54 -0
  40. package/linthost/rules_logic.go +30 -15
  41. package/linthost/rules_no_redeclare.go +12 -3
  42. package/linthost/rules_promise.go +37 -15
  43. package/linthost/rules_regexp.go +443 -49
  44. package/linthost/rules_security.go +243 -4
  45. package/linthost/rules_solid.go +430 -10
  46. package/linthost/rules_storybook.go +255 -10
  47. package/linthost/rules_suggestions.go +60 -39
  48. package/linthost/rules_ts.go +7 -0
  49. package/linthost/rules_ts_async.go +80 -2
  50. package/linthost/rules_ts_extra.go +21 -7
  51. package/linthost/serve.go +318 -0
  52. package/linthost/width_tables_gen.go +219 -0
  53. package/package.json +2 -2
  54. package/rule/hint.go +142 -0
  55. package/rule/rule.go +197 -1
  56. package/src/index.ts +35 -4
  57. package/src/structures/format/ITtscLintFormat.ts +14 -10
  58. package/src/structures/rules/ITtscLintRegexpRules.ts +24 -9
  59. package/src/structures/rules/ITtscLintSecurityRules.ts +18 -0
  60. package/src/structures/rules/ITtscLintSolidRules.ts +22 -2
  61. package/src/structures/rules/ITtscLintStorybookRules.ts +21 -0
  62. package/src/structures/rules/ITtscLintTypeScriptRuleOptions.ts +10 -1
  63. package/src/structures/rules/ITtscLintTypeScriptRules.ts +13 -2
@@ -57,9 +57,51 @@ func printArrowFunction(ctx *PrintContext, node *shimast.Node) (Doc, bool) {
57
57
  if arrow == nil || arrow.Body == nil {
58
58
  return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
59
59
  }
60
+ if arrowBodyBreaksAfterArrow(arrow.Body) {
61
+ return printArrowWithBreakableExpressionBody(ctx, node, arrow.Body)
62
+ }
60
63
  return printFunctionLike(ctx, node, arrow.Body)
61
64
  }
62
65
 
66
+ // arrowBodyBreaksAfterArrow reports the expression-body shapes for which
67
+ // Prettier can keep an arrow hugged to a call's opening line and break after
68
+ // `=>`. Object and array bodies already carry their own group and use the
69
+ // ordinary arrow printer; these shapes need a group owned by the arrow itself.
70
+ func arrowBodyBreaksAfterArrow(body *shimast.Node) bool {
71
+ if body == nil {
72
+ return false
73
+ }
74
+ switch body.Kind {
75
+ case shimast.KindCallExpression,
76
+ shimast.KindConditionalExpression,
77
+ shimast.KindJsxElement,
78
+ shimast.KindJsxSelfClosingElement,
79
+ shimast.KindJsxFragment:
80
+ return true
81
+ }
82
+ return false
83
+ }
84
+
85
+ // printArrowWithBreakableExpressionBody renders a concise arrow as one group
86
+ // with a Line immediately after `=>`. Flat mode emits the ordinary space;
87
+ // broken mode places the expression one indentation level below the arrow.
88
+ func printArrowWithBreakableExpressionBody(ctx *PrintContext, node, body *shimast.Node) (Doc, bool) {
89
+ nodeStart := shimscanner.SkipTrivia(ctx.Source, node.Pos())
90
+ bodyStart := shimscanner.SkipTrivia(ctx.Source, body.Pos())
91
+ if nodeStart < 0 || bodyStart < nodeStart || bodyStart > len(ctx.Source) {
92
+ return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
93
+ }
94
+ prefix := strings.TrimRight(ctx.Source[nodeStart:bodyStart], " \t\r\n")
95
+ if strings.Contains(prefix, "\n") || !strings.HasSuffix(prefix, "=>") {
96
+ return printFunctionLike(ctx, node, body)
97
+ }
98
+ bodyDoc, covered := PrintNode(ctx, body)
99
+ return Group(
100
+ Text(prefix),
101
+ Indent(ctx.indentUnit(), Line(), bodyDoc),
102
+ ), covered
103
+ }
104
+
63
105
  // printFunctionExpression renders a `function` expression. Like the
64
106
  // arrow printer, the signature is verbatim and only the body reflows.
65
107
  // Function expressions always carry a block body, so the body always
@@ -100,6 +142,66 @@ func printFunctionLike(ctx *PrintContext, node, body *shimast.Node) (Doc, bool)
100
142
  return Concat(prefix, bodyDoc), prefixCovered && bodyCovered
101
143
  }
102
144
 
145
+ // printObjectMember renders one member of an object literal by slicing its
146
+ // head verbatim and dispatching what follows.
147
+ //
148
+ // A member is the same shape as a function expression — a signature the printer
149
+ // keeps as written, then a body it lays out — so it goes through
150
+ // printFunctionLike. A shorthand method's body starts at its `{`, an accessor's
151
+ // likewise, and a property assignment's "body" is its initializer, which makes
152
+ // `m: () => { … }` reflow through the arrow printer instead of freezing.
153
+ //
154
+ // Without this every member printed verbatim, so an object literal held a
155
+ // single-line slice no enclosing reflow could break into: `{ m() { return 1; }
156
+ // }` had no hardline to honour however the layout was forced, which is why
157
+ // denying the print-width fast path for it changed nothing.
158
+ //
159
+ // The second return value is the `covered` flag: see PrintNode.
160
+ func printObjectMember(ctx *PrintContext, node *shimast.Node) (Doc, bool) {
161
+ if node == nil {
162
+ return Doc{}, true
163
+ }
164
+ body := objectMemberBody(node)
165
+ if body == nil {
166
+ // A shorthand property (`{ a }`), a spread (`{ ...rest }`), or a member
167
+ // whose body the parser did not produce. There is nothing to lay out, so
168
+ // the source bytes round-trip.
169
+ return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
170
+ }
171
+ // A comment between the head and the body would sit inside the verbatim
172
+ // prefix and survive, but one after the body's close brace would not, so the
173
+ // same guard the sibling printers use applies here.
174
+ if listHasInterItemComments(ctx, node) {
175
+ return verbatim(ctx, node), false
176
+ }
177
+ return printFunctionLike(ctx, node, body)
178
+ }
179
+
180
+ // objectMemberBody returns the part of an object-literal member the printer
181
+ // lays out — a callable member's block, or a property assignment's initializer
182
+ // — or nil when the member has neither.
183
+ func objectMemberBody(node *shimast.Node) *shimast.Node {
184
+ switch node.Kind {
185
+ case shimast.KindPropertyAssignment:
186
+ if p := node.AsPropertyAssignment(); p != nil {
187
+ return p.Initializer
188
+ }
189
+ case shimast.KindMethodDeclaration:
190
+ if m := node.AsMethodDeclaration(); m != nil {
191
+ return m.Body
192
+ }
193
+ case shimast.KindGetAccessor:
194
+ if g := node.AsGetAccessorDeclaration(); g != nil {
195
+ return g.Body
196
+ }
197
+ case shimast.KindSetAccessor:
198
+ if s := node.AsSetAccessorDeclaration(); s != nil {
199
+ return s.Body
200
+ }
201
+ }
202
+ return nil
203
+ }
204
+
103
205
  // printParenthesizedExpression renders `( expr )`. The parentheses are
104
206
  // fixed punctuation; the inner expression is dispatched so a call or
105
207
  // object literal wrapped in parens still reflows.
@@ -164,8 +266,27 @@ func printBlock(ctx *PrintContext, node *shimast.Node) (Doc, bool) {
164
266
  hasComment := blockHasNonStatementComment(ctx, node, block.Statements.Nodes)
165
267
  if len(block.Statements.Nodes) == 0 {
166
268
  if hasComment {
167
- // `{ /* */ }` not collapsible without dropping the comment.
168
- return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
269
+ // A one-line comment-only block is non-empty to Prettier: keep the
270
+ // comment and give it the same hardline layout as a statement. A
271
+ // multi-line comment body keeps its original columns and therefore
272
+ // remains uncovered.
273
+ start := shimscanner.SkipTrivia(ctx.Source, node.Pos())
274
+ end := node.End()
275
+ if start < 0 || end <= start+1 || end > len(ctx.Source) ||
276
+ ctx.Source[start] != '{' || ctx.Source[end-1] != '}' {
277
+ return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
278
+ }
279
+ rawInner := ctx.Source[start+1 : end-1]
280
+ inner := strings.TrimSpace(rawInner)
281
+ if inner == "" || strings.ContainsAny(rawInner, "\r\n") {
282
+ return verbatim(ctx, node), false
283
+ }
284
+ return Concat(
285
+ Text("{"),
286
+ Indent(ctx.indentUnit(), Hardline(), Text(inner)),
287
+ Hardline(),
288
+ Text("}"),
289
+ ), true
169
290
  }
170
291
  return Text("{}"), true
171
292
  }
@@ -254,30 +375,29 @@ func blankLineBetweenStatements(src string, prevEnd, nextPos int) bool {
254
375
  return false
255
376
  }
256
377
 
257
- // blockHasNonStatementComment reports whether the block's byte range
258
- // holds a `//` or `/*` outside every statement's token range. The block
259
- // printer joins statements with bare Hardlines that have no slot for
260
- // inter-statement trivia, so a stray comment would be dropped by a
261
- // reflow. Detecting it lets printBlock report the block uncovered and
262
- // the formatPrintWidth rule abstain.
378
+ // nodeHasNonItemComment reports whether the node's byte range holds a `//` or
379
+ // `/*` outside every supplied item's token range. Block and switch printers
380
+ // join their items with minted Hardlines that have no slot for inter-item
381
+ // trivia, so a stray comment would be dropped by a reflow. Detecting it lets
382
+ // the printer report the node uncovered and the formatPrintWidth rule abstain.
263
383
  //
264
384
  // The scan mirrors rules_format_print_width.go::hasNonChildComments:
265
385
  // comment-shaped bytes inside a complete statement token range (string
266
386
  // literals, nested comments) are masked, so only genuine
267
387
  // inter-statement comments surface.
268
- func blockHasNonStatementComment(ctx *PrintContext, node *shimast.Node, stmts []*shimast.Node) bool {
388
+ func nodeHasNonItemComment(ctx *PrintContext, node *shimast.Node, items []*shimast.Node) bool {
269
389
  start := shimscanner.SkipTrivia(ctx.Source, node.Pos())
270
390
  end := node.End()
271
391
  if start < 0 || end < start || end > len(ctx.Source) {
272
392
  return false
273
393
  }
274
394
  type span struct{ pos, end int }
275
- ranges := make([]span, 0, len(stmts))
276
- for _, stmt := range stmts {
277
- if stmt == nil {
395
+ ranges := make([]span, 0, len(items))
396
+ for _, item := range items {
397
+ if item == nil {
278
398
  continue
279
399
  }
280
- ranges = append(ranges, span{shimscanner.SkipTrivia(ctx.Source, stmt.Pos()), stmt.End()})
400
+ ranges = append(ranges, span{shimscanner.SkipTrivia(ctx.Source, item.Pos()), item.End()})
281
401
  }
282
402
  inStatement := func(i int) bool {
283
403
  for _, r := range ranges {
@@ -299,6 +419,13 @@ func blockHasNonStatementComment(ctx *PrintContext, node *shimast.Node, stmts []
299
419
  return false
300
420
  }
301
421
 
422
+ // blockHasNonStatementComment is the block-specific name retained for callers
423
+ // and focused tests; switch printers use the generalized item-range helper
424
+ // directly for clauses and statements.
425
+ func blockHasNonStatementComment(ctx *PrintContext, node *shimast.Node, stmts []*shimast.Node) bool {
426
+ return nodeHasNonItemComment(ctx, node, stmts)
427
+ }
428
+
302
429
  // printExpressionStatement renders an `expr;` statement. The expression
303
430
  // is dispatched so a callback-body statement that is itself a call or
304
431
  // object literal reflows; the trailing `;` is preserved when the source
@@ -52,14 +52,17 @@ type listShape struct {
52
52
  Items []Doc
53
53
  Space bool // emit a space after OPEN / before CLOSE in flat mode
54
54
  AddComma bool // emit a trailing comma in broken mode
55
- // HugLast keeps the final item attached to the parens instead of
56
- // exploding the whole list. It is set by the call/new argument
57
- // printer when the last argument is a callback or object literal —
58
- // see printArgList. When true, the last item is emitted directly
59
- // against OPEN…CLOSE with no leading/trailing soft break, so a
60
- // multi-line callback body does not force every preceding argument
61
- // onto its own line.
55
+ // HugLast keeps the final item attached to OPEN instead of exploding the
56
+ // whole list. Ordinarily it also stays against CLOSE, so a multi-line
57
+ // callback body does not force preceding arguments onto separate lines.
58
+ // HugLastBreakClose supplies the expression-bodied-arrow exception.
62
59
  HugLast bool
60
+ // HugLastBreakClose keeps an expression-bodied trailing arrow attached to
61
+ // OPEN through its `=>`, but finishes a broken argument list with the
62
+ // optional trailing comma and CLOSE on a fresh line. Unlike a block-bodied
63
+ // callback, the arrow's expression does not own the call's closing line:
64
+ // `run(() =>\n nested(),\n)`. Requires HugLast.
65
+ HugLastBreakClose bool
63
66
  // HugFirst keeps the FIRST item attached to the open paren and flows
64
67
  // the remaining simple items after it, the mirror of HugLast. The
65
68
  // call-argument printer sets it for the two-argument
@@ -274,8 +277,9 @@ func printListPlain(ctx *PrintContext, shape listShape) Doc {
274
277
 
275
278
  // printListHuggingLast renders the "last-argument hugging" shape that
276
279
  // Prettier uses for `foo(a, b, () => { … })`: the leading items flow
277
- // comma-separated and the final item stays attached to the closing
278
- // paren instead of being pushed onto its own indented line.
280
+ // comma-separated and the final item stays attached to the opening line
281
+ // instead of being pushed onto its own indented line. Most expandable items
282
+ // hug CLOSE too; an expression-bodied arrow uses HugLastBreakClose.
279
283
  //
280
284
  // hugged: OPEN a, b, OPEN-of-last … CLOSE-of-last CLOSE
281
285
  //
@@ -308,7 +312,15 @@ func printListHuggingLast(ctx *PrintContext, shape listShape) Doc {
308
312
  for _, item := range lead {
309
313
  parts = append(parts, item, Text(", "))
310
314
  }
311
- parts = append(parts, last, Text(shape.CloseTok))
315
+ parts = append(parts, last)
316
+ if shape.HugLastBreakClose {
317
+ if shape.AddComma {
318
+ parts = append(parts, Text(","))
319
+ }
320
+ parts = append(parts, Hardline(), Text(shape.CloseTok))
321
+ } else {
322
+ parts = append(parts, Text(shape.CloseTok))
323
+ }
312
324
  return Concat(parts...)
313
325
  }
314
326
 
@@ -1,6 +1,7 @@
1
1
  package linthost
2
2
 
3
3
  import (
4
+ "encoding/json"
4
5
  "fmt"
5
6
  "sort"
6
7
  "sync"
@@ -18,6 +19,11 @@ type projectCycleResult struct {
18
19
  status publicrule.ProjectRuleStatus
19
20
  severity Severity
20
21
  reporter *projectReporter
22
+ // identity and options are the configuration this rule ran under, retained so
23
+ // a later consumer of its state — a hint corpus, say — can be handed the same
24
+ // context Check saw without the rule stashing a copy inside its own state.
25
+ identity publicrule.ProjectIdentity
26
+ options json.RawMessage
21
27
  }
22
28
 
23
29
  func (r *projectCycleResults) ProjectResult(name string) publicrule.ProjectRuleResult {
@@ -178,11 +184,20 @@ func (e *Engine) evaluateProject(
178
184
  sources = e.projectSources(files)
179
185
  sourcesResolved = true
180
186
  }
187
+ // A rule that declared it needs no checker is handed none, matching the
188
+ // TypeAwareRule contract that the host is free to leave it nil. Passing one
189
+ // anyway would let such a rule read the checker and keep working while its
190
+ // own declaration says it does not — until the day the engine acts on that
191
+ // declaration and the rule faults far from the marker that caused it.
192
+ ruleChecker := checker
193
+ if adapter.declinesTypeChecker {
194
+ ruleChecker = nil
195
+ }
181
196
  reporter := &projectReporter{active: true}
182
197
  context := publicrule.NewProjectContext(
183
198
  identity,
184
199
  sources,
185
- checker,
200
+ ruleChecker,
186
201
  publicrule.Severity(setting.Severity),
187
202
  setting.Options,
188
203
  reporter,
@@ -191,6 +206,8 @@ func (e *Engine) evaluateProject(
191
206
  results.byName[name] = projectCycleResult{
192
207
  severity: setting.Severity,
193
208
  reporter: reporter,
209
+ identity: identity,
210
+ options: setting.Options,
194
211
  }
195
212
  }
196
213
  return cycle
@@ -14,10 +14,48 @@ type projectRuleAdapter struct {
14
14
  inner publicrule.ProjectRule
15
15
  name string
16
16
  acceptsOptions bool
17
+ // declinesTypeChecker records an explicit opt-out, so the zero value keeps
18
+ // the conservative default: a rule that never spoke still receives a checker.
19
+ //
20
+ // The negative spelling is deliberate. Adapters are constructed directly in
21
+ // several test helpers rather than through inspectProjectContributor, and a
22
+ // positive needsTypeChecker field would make those zero values silently deny
23
+ // a checker to a rule that reads one — the unsafe direction, reached by
24
+ // forgetting rather than by deciding.
25
+ declinesTypeChecker bool
17
26
  }
18
27
 
19
28
  var registeredProjectRules = map[string]projectRuleAdapter{}
20
29
 
30
+ // registerBuiltInProjectCompanion attaches a project lifecycle to an existing
31
+ // built-in file rule. Both halves deliberately share one public rule name and
32
+ // therefore one config setting: the file rule keeps reporting source ranges,
33
+ // while the project companion can publish finished state for consumers such as
34
+ // editor hints.
35
+ //
36
+ // Contributor rules remain single-lifecycle registrations. Their collision
37
+ // checks in registerProjectContributors still reject a file/project name pair,
38
+ // because only built-ins can be audited together as one rule implementation.
39
+ func registerBuiltInProjectCompanion(project publicrule.ProjectRule) {
40
+ adapter, err := inspectProjectContributor(project)
41
+ if err != nil {
42
+ panic(fmt.Sprintf("@ttsc/lint: invalid built-in project companion: %v", err))
43
+ }
44
+ if adapter.name == "" {
45
+ panic("@ttsc/lint: built-in project companion has an empty name")
46
+ }
47
+ if LookupRule(adapter.name) == nil {
48
+ panic(fmt.Sprintf("@ttsc/lint: built-in project companion %q has no file rule", adapter.name))
49
+ }
50
+ if _, builtIn := builtInRuleCodes[adapter.name]; !builtIn {
51
+ panic(fmt.Sprintf("@ttsc/lint: project companion %q is not a built-in rule", adapter.name))
52
+ }
53
+ if _, exists := registeredProjectRules[adapter.name]; exists {
54
+ panic(fmt.Sprintf("@ttsc/lint: project rule %q registered twice", adapter.name))
55
+ }
56
+ registeredProjectRules[adapter.name] = adapter
57
+ }
58
+
21
59
  func registerProjectContributors() {
22
60
  projects := publicrule.RegisteredProjects()
23
61
  adapters := make([]projectRuleAdapter, 0, len(projects))
@@ -65,9 +103,18 @@ func inspectProjectContributor(project publicrule.ProjectRule) (adapter projectR
65
103
  if optionsRule, ok := project.(publicrule.OptionsRule); ok {
66
104
  adapter.acceptsOptions = optionsRule.AcceptsTtscLintOptions()
67
105
  }
106
+ if typeAware, ok := project.(publicrule.TypeAwareRule); ok {
107
+ adapter.declinesTypeChecker = !typeAware.NeedsTypeChecker()
108
+ }
68
109
  return adapter, nil
69
110
  }
70
111
 
112
+ // projectRuleNeedsTypeChecker reports whether a registered project rule wants a
113
+ // live checker. An unknown name and a rule that never spoke both answer true.
114
+ func projectRuleNeedsTypeChecker(name string) bool {
115
+ return !registeredProjectRules[name].declinesTypeChecker
116
+ }
117
+
71
118
  func allProjectRuleNames() []string {
72
119
  names := make([]string, 0, len(registeredProjectRules))
73
120
  for name := range registeredProjectRules {
@@ -0,0 +1,114 @@
1
+ package linthost
2
+
3
+ import "strings"
4
+
5
+ // Rule documentation lives in one of two places, and which one is decided by
6
+ // the rule's family alone — no per-rule table is maintained here.
7
+ //
8
+ // Families ported from a plugin that publishes its own rule reference keep
9
+ // pointing at that upstream reference, because those pages are the behavioral
10
+ // specification `@ttsc/lint` ports against and the one the rule sources already
11
+ // cite in their header comments. Every other family is documented only by the
12
+ // ttsc website's rule catalog, so its rules link there.
13
+ const (
14
+ eslintRuleDocsBaseURL = "https://eslint.org/docs/latest/rules/"
15
+ unicornRuleDocsBaseURL = "https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/"
16
+ typescriptRuleDocsBaseURL = "https://typescript-eslint.io/rules/"
17
+ websiteRuleDocsBaseURL = "https://ttsc.dev/docs/lint/rules/"
18
+ )
19
+
20
+ // websiteDocumentedRuleFamilies lists the families whose whole rule set is
21
+ // catalogued on the ttsc website, as one level-3 heading per rule (the rule
22
+ // name in a code span) in website/src/content/docs/lint/rules/<family>.mdx.
23
+ //
24
+ // `format` is deliberately absent. Its 17 rules have no page of their own: the
25
+ // `lint/format` guide documents the configuration keys (`semi`, `printWidth`,
26
+ // …), which do not correspond one-to-one with the rule names (`format/quotes`,
27
+ // `format/clause-join`, `format/whitespace`, …). Emitting an anchor there would
28
+ // produce a link to a heading that does not exist, so format findings carry no
29
+ // codeDescription at all.
30
+ //
31
+ // A family missing from this set simply gets no documentation link, which keeps
32
+ // a newly added family silent rather than pointing at a page nobody wrote yet.
33
+ var websiteDocumentedRuleFamilies = map[string]struct{}{
34
+ "boundaries": {},
35
+ "cypress": {},
36
+ "functional": {},
37
+ "jest": {},
38
+ "jsdoc": {},
39
+ "jsx-a11y": {},
40
+ "nextjs": {},
41
+ "playwright": {},
42
+ "promise": {},
43
+ "react": {},
44
+ "react-perf": {},
45
+ "regexp": {},
46
+ "security": {},
47
+ "solid": {},
48
+ "storybook": {},
49
+ "tanstack-query": {},
50
+ "testing-library": {},
51
+ "vitest": {},
52
+ }
53
+
54
+ // ruleDocumentationURL returns the documentation page for a rule name, or an
55
+ // empty string when no vetted page exists for it.
56
+ //
57
+ // Only active built-in rules resolve. The rule-code ledger is append-only, so a
58
+ // removed built-in name remains reserved and is not enough to establish runtime
59
+ // provenance by itself. The registered rule must also be a native rule rather
60
+ // than a public contributor adapter. That keeps a contributor which reuses a
61
+ // retired ledger name from inheriting a ttsc or upstream URL.
62
+ func ruleDocumentationURL(name string) string {
63
+ candidate := LookupRule(name)
64
+ if candidate == nil {
65
+ return ""
66
+ }
67
+ switch candidate.(type) {
68
+ case contributorAdapter, formatContributorAdapter:
69
+ return ""
70
+ }
71
+ if _, builtIn := builtInRuleCodes[name]; !builtIn {
72
+ return ""
73
+ }
74
+ family, bare, prefixed := strings.Cut(name, "/")
75
+ if !prefixed {
76
+ // An unprefixed built-in name is a core ESLint rule id verbatim.
77
+ return eslintRuleDocsBaseURL + name
78
+ }
79
+ switch family {
80
+ case "unicorn":
81
+ return unicornRuleDocsBaseURL + bare + ".md"
82
+ case "typescript":
83
+ return typescriptRuleDocsBaseURL + bare
84
+ }
85
+ if _, documented := websiteDocumentedRuleFamilies[family]; !documented {
86
+ return ""
87
+ }
88
+ return websiteRuleDocsBaseURL + family + "#" + websiteRuleAnchor(name)
89
+ }
90
+
91
+ // websiteRuleAnchor reproduces the heading id Nextra assigns to the level-3
92
+ // heading that documents a rule.
93
+ //
94
+ // Nextra's remark-headings plugin slugs the heading's flattened text with
95
+ // github-slugger, which lowercases and drops characters it does not consider
96
+ // word characters — the `/` separator among them. `jsx-a11y/alt-text` therefore
97
+ // renders as `#jsx-a11yalt-text`, not `#jsx-a11y-alt-text`, and the one rule
98
+ // name carrying an uppercase letter (`security/detect-pseudoRandomBytes`)
99
+ // renders fully lowercased. Both fall out of this transform; neither is special
100
+ // cased.
101
+ func websiteRuleAnchor(name string) string {
102
+ return strings.ToLower(strings.ReplaceAll(name, "/", ""))
103
+ }
104
+
105
+ // lspCodeDescriptionForRule wraps a rule's documentation URL for the wire, or
106
+ // returns nil when the rule has none. A nil result keeps `codeDescription`
107
+ // absent from the marshalled diagnostic rather than emitting an empty object.
108
+ func lspCodeDescriptionForRule(name string) *lspCodeDescription {
109
+ href := ruleDocumentationURL(name)
110
+ if href == "" {
111
+ return nil
112
+ }
113
+ return &lspCodeDescription{Href: href}
114
+ }
@@ -59,7 +59,10 @@ func (boundariesElementTypes) Check(ctx *Context, node *shimast.Node) {
59
59
  }
60
60
  message := fmt.Sprintf("Import from boundary element %q is not allowed in %q.", target.Type, source.Type)
61
61
  if rule != nil && rule.Message != "" {
62
+ // A user wrote this; appending to it would be presumptuous.
62
63
  message = rule.Message
64
+ } else if rule != nil {
65
+ message += describeBoundaryAllowed(rule.Allow)
63
66
  }
64
67
  reportBoundaryDependency(ctx, dep, message)
65
68
  }
@@ -80,6 +83,8 @@ func (boundariesExternal) Check(ctx *Context, node *shimast.Node) {
80
83
  message := fmt.Sprintf("External dependency %q is not allowed.", dep.specifier)
81
84
  if opts.Message != "" {
82
85
  message = opts.Message
86
+ } else {
87
+ message += describeBoundaryAllowed(opts.Allow)
83
88
  }
84
89
  reportBoundaryDependency(ctx, dep, message)
85
90
  }
@@ -109,7 +114,11 @@ func (boundariesEntryPoint) Check(ctx *Context, node *shimast.Node) {
109
114
  if matchBoundaryElementLocalPattern(target.Entry, target) {
110
115
  continue
111
116
  }
112
- reportBoundaryDependency(ctx, dep, fmt.Sprintf("Import %q through an allowed boundary entry point.", target.RelativePath))
117
+ reportBoundaryDependency(ctx, dep, fmt.Sprintf(
118
+ "Import %q through an allowed boundary entry point.%s",
119
+ target.RelativePath,
120
+ describeBoundaryAllowed(target.Entry),
121
+ ))
113
122
  }
114
123
  }
115
124
 
@@ -157,7 +166,11 @@ func (boundariesNoUnknown) Check(ctx *Context, node *shimast.Node) {
157
166
  if classifyBoundaryFile(targetPath, opts.Elements) != nil {
158
167
  continue
159
168
  }
160
- reportBoundaryDependency(ctx, dep, fmt.Sprintf("Imported file %q does not match any configured boundary element.", boundaryDisplayPath(targetPath)))
169
+ reportBoundaryDependency(ctx, dep, fmt.Sprintf(
170
+ "Imported file %q does not match any configured boundary element.%s",
171
+ boundaryDisplayPath(targetPath),
172
+ describeBoundaryElements(opts.Elements),
173
+ ))
161
174
  }
162
175
  }
163
176
 
@@ -460,6 +473,71 @@ func resolveBoundaryImport(sourceFileName, specifier string) (string, bool) {
460
473
  return "", false
461
474
  }
462
475
 
476
+ // describeBoundaryAllowed renders a set of allowed patterns as a clause a
477
+ // message can append, or "" when there is nothing to name.
478
+ //
479
+ // A boundary rule computes what would be acceptable in order to decide, then
480
+ // reports only that the code is wrong — leaving the reader to open lint.config
481
+ // to learn what is allowed. Naming the set closes that gap for the cost of a
482
+ // join, and matches what unicorn/import-style and prevent-abbreviations already
483
+ // do. An empty set returns "" rather than an empty list, because a deny-only
484
+ // policy has no allowed set to offer and "allowed here: " with nothing after it
485
+ // is worse than silence.
486
+ func describeBoundaryAllowed(patterns boundaryStringList) string {
487
+ if len(patterns) == 0 {
488
+ return ""
489
+ }
490
+ items := make([]string, 0, len(patterns))
491
+ for _, pattern := range patterns {
492
+ if pattern == "" {
493
+ continue
494
+ }
495
+ items = append(items, pattern)
496
+ }
497
+ if len(items) == 0 {
498
+ return ""
499
+ }
500
+ // Truncate rather than print a wall. A message that scrolls off the line is
501
+ // its own failure, and the first several patterns are enough to orient.
502
+ const limit = 6
503
+ suffix := ""
504
+ if len(items) > limit {
505
+ suffix = ", ..."
506
+ items = items[:limit]
507
+ }
508
+ return " Allowed here: " + strings.Join(items, ", ") + suffix + "."
509
+ }
510
+
511
+ // describeBoundaryElements names the configured element types, for a
512
+ // "does not match any element" message that would otherwise make the reader
513
+ // go find the list themselves. Phrased as "Configured elements" rather than
514
+ // "Allowed", because no-unknown is about which elements exist, not which imports
515
+ // a policy permits.
516
+ func describeBoundaryElements(elements []boundaryElement) string {
517
+ types := make([]string, 0, len(elements))
518
+ seen := map[string]struct{}{}
519
+ for _, element := range elements {
520
+ if element.Type == "" {
521
+ continue
522
+ }
523
+ if _, ok := seen[element.Type]; ok {
524
+ continue
525
+ }
526
+ seen[element.Type] = struct{}{}
527
+ types = append(types, element.Type)
528
+ }
529
+ if len(types) == 0 {
530
+ return ""
531
+ }
532
+ const limit = 6
533
+ suffix := ""
534
+ if len(types) > limit {
535
+ suffix = ", ..."
536
+ types = types[:limit]
537
+ }
538
+ return " Configured elements: " + strings.Join(types, ", ") + suffix + "."
539
+ }
540
+
463
541
  func reportBoundaryDependency(ctx *Context, dep boundaryDependency, message string) {
464
542
  if dep.node != nil {
465
543
  ctx.Report(dep.node, message)
@@ -13,9 +13,10 @@ import (
13
13
  // - prefer false: no inner space, `{x: 1}`, `{a, b}`, `import {foo}`.
14
14
  //
15
15
  // It applies to object literals, object binding patterns (destructuring),
16
- // named imports/exports, and type literals, the brace kinds Prettier's
17
- // bracketSpacing governs. Block, class, interface, enum, and module braces
18
- // are NOT affected (their layout is owned by the indentation rules).
16
+ // named imports/exports, type literals, mapped types, and import attributes,
17
+ // the brace kinds Prettier's bracketSpacing governs. Block, class, interface,
18
+ // enum, and module braces are NOT affected (their layout is owned by the
19
+ // indentation rules).
19
20
  //
20
21
  // The rule touches only a brace pair that opens and closes on the SAME
21
22
  // line: a multi-line container's interior is the indentation rules' surface,
@@ -38,6 +39,8 @@ func (formatBracketSpacing) Visits() []shimast.Kind {
38
39
  shimast.KindNamedImports,
39
40
  shimast.KindNamedExports,
40
41
  shimast.KindTypeLiteral,
42
+ shimast.KindMappedType,
43
+ shimast.KindImportAttributes,
41
44
  }
42
45
  }
43
46
 
@@ -58,11 +61,20 @@ func (formatBracketSpacing) Check(ctx *Context, node *shimast.Node) {
58
61
  if start < 0 || end <= start || end > len(src) {
59
62
  return
60
63
  }
61
- // The node's first byte should be `{` and last `}`; a parenthesized or
62
- // otherwise-wrapped form is out of scope.
63
- if src[start] != '{' || src[end-1] != '}' {
64
+ if src[end-1] != '}' {
64
65
  return
65
66
  }
67
+ // ImportAttributes begins at `with` or `assert`, not at its opening brace.
68
+ // Every other supported kind begins with `{`. Restrict the fallback search
69
+ // to that syntax node so a brace in an earlier expression cannot be chosen.
70
+ if src[start] != '{' {
71
+ for start < end && src[start] != '{' {
72
+ start++
73
+ }
74
+ if start >= end {
75
+ return
76
+ }
77
+ }
66
78
  inner := src[start+1 : end-1]
67
79
  if len(inner) == 0 {
68
80
  return // empty `{}`, nothing to pad