@ttsc/lint 0.18.4 → 0.19.1

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 (188) hide show
  1. package/README.md +125 -43
  2. package/go.mod +5 -4
  3. package/internal/rulecode/rulecode.go +88 -0
  4. package/lib/index.d.ts +10 -1
  5. package/lib/index.js +45 -17
  6. package/lib/index.js.map +1 -1
  7. package/lib/structures/ITtscLintConfig.d.ts +3 -0
  8. package/lib/structures/TtscLintRuleSetting.d.ts +3 -2
  9. package/lib/structures/format/ITtscLintFormat.d.ts +3 -2
  10. package/lib/structures/format/ITtscLintFormatSortImports.d.ts +20 -4
  11. package/lib/structures/rules/ITtscLintBoundariesRuleOptions.d.ts +80 -8
  12. package/lib/structures/rules/ITtscLintBoundariesRules.d.ts +6 -8
  13. package/lib/structures/rules/ITtscLintContributorRules.d.ts +18 -2
  14. package/lib/structures/rules/ITtscLintCoreRuleOptions.d.ts +347 -0
  15. package/lib/structures/rules/ITtscLintCoreRuleOptions.js +3 -0
  16. package/lib/structures/rules/ITtscLintCoreRuleOptions.js.map +1 -0
  17. package/lib/structures/rules/ITtscLintCoreRules.d.ts +101 -67
  18. package/lib/structures/rules/ITtscLintRegexpRules.d.ts +7 -2
  19. package/lib/structures/rules/ITtscLintRuleOptionsMap.d.ts +53 -7
  20. package/lib/structures/rules/ITtscLintRules.d.ts +6 -2
  21. package/lib/structures/rules/ITtscLintSolidRules.d.ts +0 -8
  22. package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.d.ts +206 -0
  23. package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.js +8 -0
  24. package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.js.map +1 -0
  25. package/lib/structures/rules/ITtscLintTypeScriptRules.d.ts +55 -42
  26. package/lib/structures/rules/ITtscLintUnicornRuleOptions.d.ts +276 -0
  27. package/lib/structures/rules/ITtscLintUnicornRuleOptions.js +3 -0
  28. package/lib/structures/rules/ITtscLintUnicornRuleOptions.js.map +1 -0
  29. package/lib/structures/rules/ITtscLintUnicornRules.d.ts +44 -36
  30. package/lib/structures/rules/index.d.ts +3 -0
  31. package/lib/structures/rules/index.js +3 -0
  32. package/lib/structures/rules/index.js.map +1 -1
  33. package/linthost/ast_helpers.go +521 -23
  34. package/linthost/ast_selector_matcher.go +1628 -0
  35. package/linthost/ast_selector_parser.go +628 -0
  36. package/linthost/comment_scan.go +154 -0
  37. package/linthost/compile.go +129 -79
  38. package/linthost/config.go +352 -88
  39. package/linthost/config_format.go +15 -1
  40. package/linthost/contrib_adapter.go +47 -8
  41. package/linthost/declaration_rules.go +12 -11
  42. package/linthost/directives.go +9 -67
  43. package/linthost/dispatch.go +37 -3
  44. package/linthost/engine.go +368 -53
  45. package/linthost/escape_string.go +77 -0
  46. package/linthost/fix.go +133 -23
  47. package/linthost/flags_gen.go +18 -17
  48. package/linthost/format.go +73 -167
  49. package/linthost/format_editor_settings.go +134 -26
  50. package/linthost/grapheme_tables_gen.go +1920 -0
  51. package/linthost/host.go +159 -61
  52. package/linthost/literal_escape_scan.go +117 -0
  53. package/linthost/lsp.go +545 -98
  54. package/linthost/numeric_literal_precision.go +228 -0
  55. package/linthost/polyfill_browserslist.go +1439 -0
  56. package/linthost/polyfill_browserslist_config.go +603 -0
  57. package/linthost/polyfill_corejs.go +750 -0
  58. package/linthost/polyfill_data.go +113 -0
  59. package/linthost/polyfill_data_gen.json +39868 -0
  60. package/linthost/print_nodes_array.go +7 -1
  61. package/linthost/print_nodes_object.go +7 -1
  62. package/linthost/project_engine.go +231 -0
  63. package/linthost/project_rules.go +78 -0
  64. package/linthost/regex_clean.go +191 -0
  65. package/linthost/regex_tree.go +1544 -0
  66. package/linthost/regex_tree_optimizer.go +1685 -0
  67. package/linthost/regexp_cache.go +44 -0
  68. package/linthost/rule_codes.go +105 -0
  69. package/linthost/rule_codes.json +745 -0
  70. package/linthost/rules_ban_ts_comment.go +312 -0
  71. package/linthost/rules_boundaries.go +128 -43
  72. package/linthost/rules_boundaries_dependencies.go +1013 -0
  73. package/linthost/rules_core_extra.go +62 -92
  74. package/linthost/rules_cypress.go +1 -1
  75. package/linthost/rules_default_case.go +87 -11
  76. package/linthost/rules_dupes.go +38 -13
  77. package/linthost/rules_empty.go +188 -20
  78. package/linthost/rules_escape.go +62 -48
  79. package/linthost/rules_format_arrow_parens.go +19 -5
  80. package/linthost/rules_format_bracket_spacing.go +1 -1
  81. package/linthost/rules_format_clause_join.go +1 -1
  82. package/linthost/rules_format_declaration_header.go +31 -19
  83. package/linthost/rules_format_indent.go +1 -1
  84. package/linthost/rules_format_jsdoc.go +1 -1
  85. package/linthost/rules_format_orphan_semi.go +1 -1
  86. package/linthost/rules_format_parameter_properties.go +17 -7
  87. package/linthost/rules_format_print_width.go +1 -1
  88. package/linthost/rules_format_quote_props.go +1 -1
  89. package/linthost/rules_format_quotes.go +1 -1
  90. package/linthost/rules_format_semi.go +106 -79
  91. package/linthost/rules_format_sort_imports.go +79 -45
  92. package/linthost/rules_format_statement_split.go +1 -1
  93. package/linthost/rules_format_ternary_nullish_parens.go +1 -1
  94. package/linthost/rules_format_trailing_comma.go +64 -1
  95. package/linthost/rules_format_whitespace.go +1 -1
  96. package/linthost/rules_functional.go +21 -22
  97. package/linthost/rules_gap.go +80 -3
  98. package/linthost/rules_grouped_accessor_pairs.go +84 -25
  99. package/linthost/rules_guard_for_in.go +64 -147
  100. package/linthost/rules_jsx_a11y.go +232 -22
  101. package/linthost/rules_logic.go +84 -38
  102. package/linthost/rules_nextjs.go +105 -16
  103. package/linthost/rules_no_class_assign.go +70 -0
  104. package/linthost/rules_no_duplicate_imports.go +266 -0
  105. package/linthost/rules_no_else_return.go +156 -38
  106. package/linthost/rules_no_extend_native.go +113 -17
  107. package/linthost/rules_no_fallthrough.go +1013 -0
  108. package/linthost/rules_no_func_assign.go +70 -0
  109. package/linthost/rules_no_import_assign.go +278 -86
  110. package/linthost/rules_no_inner_declarations.go +186 -0
  111. package/linthost/rules_no_loop_func.go +438 -53
  112. package/linthost/rules_no_mixed_operators.go +208 -52
  113. package/linthost/rules_no_param_reassign.go +376 -61
  114. package/linthost/rules_no_promise_executor_return.go +156 -0
  115. package/linthost/rules_no_restricted_imports.go +1013 -57
  116. package/linthost/rules_no_restricted_syntax.go +190 -26
  117. package/linthost/rules_no_useless_constructor.go +6 -36
  118. package/linthost/rules_problems.go +154 -311
  119. package/linthost/rules_promise.go +2578 -109
  120. package/linthost/rules_react_extras.go +71 -45
  121. package/linthost/rules_react_hooks.go +18 -2
  122. package/linthost/rules_react_perf.go +1 -0
  123. package/linthost/rules_react_refresh.go +1 -1
  124. package/linthost/rules_regexp.go +230 -14
  125. package/linthost/rules_security.go +45 -19
  126. package/linthost/rules_self.go +21 -1
  127. package/linthost/rules_solid.go +13 -7
  128. package/linthost/rules_storybook.go +1 -1
  129. package/linthost/rules_suggestions.go +490 -84
  130. package/linthost/rules_testing_library.go +12 -1
  131. package/linthost/rules_ts.go +227 -54
  132. package/linthost/rules_ts_async.go +0 -131
  133. package/linthost/rules_ts_no_base_to_string.go +3 -4
  134. package/linthost/rules_ts_no_misused_promises.go +817 -0
  135. package/linthost/rules_ts_no_restricted_types.go +320 -34
  136. package/linthost/rules_ts_no_unnecessary_qualifier.go +132 -55
  137. package/linthost/rules_ts_no_unsafe_assignment.go +608 -60
  138. package/linthost/rules_ts_require_array_sort_compare.go +5 -5
  139. package/linthost/rules_ts_switch_exhaustiveness_check.go +556 -86
  140. package/linthost/rules_unicorn_better_regex.go +224 -16
  141. package/linthost/rules_unicorn_consistent_destructuring.go +1002 -7
  142. package/linthost/rules_unicorn_consistent_existence_index_check.go +208 -29
  143. package/linthost/rules_unicorn_consistent_function_scoping.go +1041 -8
  144. package/linthost/rules_unicorn_consistent_template_literal_escape.go +178 -7
  145. package/linthost/rules_unicorn_escape_case.go +30 -19
  146. package/linthost/rules_unicorn_filename_case.go +763 -16
  147. package/linthost/rules_unicorn_import_style.go +783 -17
  148. package/linthost/rules_unicorn_isolated_functions.go +934 -14
  149. package/linthost/rules_unicorn_no_hex_escape.go +26 -11
  150. package/linthost/rules_unicorn_no_typeof_undefined.go +234 -25
  151. package/linthost/rules_unicorn_no_unnecessary_polyfills.go +376 -21
  152. package/linthost/rules_unicorn_no_unused_properties.go +756 -19
  153. package/linthost/rules_unicorn_number_literal_case.go +58 -39
  154. package/linthost/rules_unicorn_prefer_at.go +7 -3
  155. package/linthost/rules_unicorn_prefer_number_properties.go +381 -48
  156. package/linthost/rules_unicorn_prefer_optional_catch_binding.go +24 -24
  157. package/linthost/rules_unicorn_prefer_simple_condition_first.go +597 -19
  158. package/linthost/rules_unicorn_prefer_string_raw.go +147 -16
  159. package/linthost/rules_unicorn_prevent_abbreviations.go +1963 -95
  160. package/linthost/rules_unicorn_string_content.go +611 -15
  161. package/linthost/rules_unicorn_switch_case_break_position.go +172 -15
  162. package/linthost/rules_unicorn_template_indent.go +923 -18
  163. package/linthost/rules_unicorn_text_encoding_identifier_case.go +202 -42
  164. package/linthost/rules_unicorn_throw_new_error.go +218 -26
  165. package/linthost/rules_var.go +829 -142
  166. package/linthost/string_length.go +222 -0
  167. package/package.json +4 -3
  168. package/rule/project.go +206 -0
  169. package/rule/rule.go +58 -1
  170. package/src/index.ts +58 -17
  171. package/src/structures/ITtscLintConfig.ts +3 -0
  172. package/src/structures/TtscLintRuleSetting.ts +3 -2
  173. package/src/structures/format/ITtscLintFormat.ts +3 -2
  174. package/src/structures/format/ITtscLintFormatSortImports.ts +21 -4
  175. package/src/structures/rules/ITtscLintBoundariesRuleOptions.ts +122 -8
  176. package/src/structures/rules/ITtscLintBoundariesRules.ts +6 -8
  177. package/src/structures/rules/ITtscLintContributorRules.ts +18 -2
  178. package/src/structures/rules/ITtscLintCoreRuleOptions.ts +465 -0
  179. package/src/structures/rules/ITtscLintCoreRules.ts +122 -67
  180. package/src/structures/rules/ITtscLintRegexpRules.ts +7 -2
  181. package/src/structures/rules/ITtscLintRuleOptionsMap.ts +88 -7
  182. package/src/structures/rules/ITtscLintRules.ts +7 -2
  183. package/src/structures/rules/ITtscLintSolidRules.ts +0 -9
  184. package/src/structures/rules/ITtscLintTypeScriptRuleOptions.ts +248 -0
  185. package/src/structures/rules/ITtscLintTypeScriptRules.ts +64 -42
  186. package/src/structures/rules/ITtscLintUnicornRuleOptions.ts +350 -0
  187. package/src/structures/rules/ITtscLintUnicornRules.ts +60 -36
  188. package/src/structures/rules/index.ts +3 -0
package/linthost/lsp.go CHANGED
@@ -1,6 +1,8 @@
1
1
  package linthost
2
2
 
3
3
  import (
4
+ "context"
5
+ "crypto/sha256"
4
6
  "encoding/json"
5
7
  "errors"
6
8
  "flag"
@@ -17,11 +19,14 @@ import (
17
19
  shimast "github.com/microsoft/typescript-go/shim/ast"
18
20
  shimcore "github.com/microsoft/typescript-go/shim/core"
19
21
  shimparser "github.com/microsoft/typescript-go/shim/parser"
22
+
23
+ publicrule "github.com/samchon/ttsc/packages/lint/rule"
20
24
  )
21
25
 
22
26
  const (
23
- commandLintFixAll = "ttsc.lint.fixAll"
24
- commandFormatDocument = "ttsc.format.document"
27
+ commandLintFixAll = "ttsc.lint.fixAll"
28
+ commandLintApplySuggestion = "ttsc.lint.applySuggestion"
29
+ commandFormatDocument = "ttsc.format.document"
25
30
  )
26
31
 
27
32
  type lspPosition struct {
@@ -34,6 +39,16 @@ type lspRange struct {
34
39
  End lspPosition `json:"end"`
35
40
  }
36
41
 
42
+ type lspPositionWire struct {
43
+ Line *int `json:"line"`
44
+ Character *int `json:"character"`
45
+ }
46
+
47
+ type lspRangeWire struct {
48
+ Start *lspPositionWire `json:"start"`
49
+ End *lspPositionWire `json:"end"`
50
+ }
51
+
37
52
  type lspDiagnostic struct {
38
53
  Range lspRange `json:"range"`
39
54
  Severity int `json:"severity,omitempty"`
@@ -68,6 +83,27 @@ type lspWorkspaceEdit struct {
68
83
  Changes map[string][]lspTextEdit `json:"changes,omitempty"`
69
84
  }
70
85
 
86
+ type lspSuggestionSelection struct {
87
+ Rule string `json:"rule"`
88
+ Message string `json:"message"`
89
+ Pos int `json:"pos"`
90
+ End int `json:"end"`
91
+ SuggestionIndex int `json:"suggestionIndex"`
92
+ Title string `json:"title"`
93
+ SourceHash string `json:"sourceHash"`
94
+ Fingerprint string `json:"fingerprint"`
95
+ }
96
+
97
+ type lspProjectDiagnostics struct {
98
+ URI string `json:"uri"`
99
+ Diagnostics []lspDiagnostic `json:"diagnostics"`
100
+ }
101
+
102
+ type lspDiagnosticsResult struct {
103
+ Document []lspDiagnostic `json:"document"`
104
+ Project *lspProjectDiagnostics `json:"project,omitempty"`
105
+ }
106
+
71
107
  type lspCommandOptions struct {
72
108
  argumentsJSON string
73
109
  command string
@@ -79,24 +115,26 @@ type lspCommandOptions struct {
79
115
  contentStdin bool
80
116
  cwd string
81
117
  pluginsJSON string
82
- // rangeJSON is accepted from the ttsc LSP server
83
- // (`internal/lspserver/lsp_native_plugin_source.go`) for forward
84
- // compatibility, but no current code path consumes it — code actions
85
- // always operate on the full file. Range-aware actions can read this
86
- // field once they're implemented.
87
- rangeJSON string
88
- tsconfig string
89
- uri string
118
+ // rangeJSON carries the editor selection used to limit quickfix.ttsc
119
+ // actions. Source fix-all and format actions remain document-wide.
120
+ rangeJSON string
121
+ tsconfig string
122
+ uri string
123
+ projectIdentity publicrule.ProjectIdentity
90
124
  }
91
125
 
92
126
  // RunLSPCommandIDs prints the workspace/executeCommand ids owned by @ttsc/lint.
93
127
  func RunLSPCommandIDs([]string) int {
94
- return writeJSON([]string{commandLintFixAll, commandFormatDocument})
128
+ return writeJSON([]string{
129
+ commandLintFixAll,
130
+ commandLintApplySuggestion,
131
+ commandFormatDocument,
132
+ })
95
133
  }
96
134
 
97
135
  // RunLSPCodeActionKinds prints the CodeActionKind values @ttsc/lint may return.
98
136
  func RunLSPCodeActionKinds([]string) int {
99
- return writeJSON([]string{"source.fixAll.ttsc", "source.format"})
137
+ return writeJSON([]string{"quickfix.ttsc", "source.fixAll.ttsc", "source.format"})
100
138
  }
101
139
 
102
140
  // RunLSPDiagnostics prints lint diagnostics for one file URI as LSP JSON.
@@ -105,18 +143,28 @@ func RunLSPDiagnostics(args []string) int {
105
143
  if !ok {
106
144
  return 2
107
145
  }
108
- findings, _, closeProgram, code := lspFindings(opts, false)
146
+ findings, prog, closeProgram, code := lspFindings(opts, false)
109
147
  if closeProgram != nil {
110
148
  defer closeProgram()
111
149
  }
112
150
  if code != 0 {
113
151
  return code
114
152
  }
115
- diagnostics := make([]lspDiagnostic, 0, len(findings))
116
- for _, finding := range findings {
117
- diagnostics = append(diagnostics, findingToLSPDiagnostic(finding))
153
+ result := lspDiagnosticsResult{Document: []lspDiagnostic{}}
154
+ for _, finding := range filterFindingsForPath(findings, mustFilePathFromURI(opts.uri)) {
155
+ result.Document = append(result.Document, findingToLSPDiagnostic(finding))
118
156
  }
119
- return writeJSON(diagnostics)
157
+ if prog != nil && prog.projectCycle != nil && len(prog.projectCycle.results.byName) > 0 {
158
+ publication := &lspProjectDiagnostics{
159
+ URI: fileURL(prog.identity.LogicalConfigPath),
160
+ Diagnostics: []lspDiagnostic{},
161
+ }
162
+ for _, finding := range prog.projectCycle.findings {
163
+ publication.Diagnostics = append(publication.Diagnostics, findingToLSPDiagnostic(finding))
164
+ }
165
+ result.Project = publication
166
+ }
167
+ return writeJSON(result)
120
168
  }
121
169
 
122
170
  // RunLSPCodeActions prints code actions available for one file URI/range.
@@ -126,8 +174,13 @@ func RunLSPCodeActions(args []string) int {
126
174
  return 2
127
175
  }
128
176
  acceptsLint := acceptsActionKind(opts.contextJSON, "source.fixAll.ttsc")
177
+ acceptsQuickFix := acceptsActionKind(opts.contextJSON, "quickfix.ttsc")
129
178
  acceptsFormat := acceptsActionKind(opts.contextJSON, "source.format")
130
- if !acceptsLint && !acceptsFormat {
179
+ quickFixRange, quickFixRangeOK := parseRequestedLSPRange(opts.rangeJSON)
180
+ if acceptsQuickFix && !quickFixRangeOK {
181
+ acceptsQuickFix = false
182
+ }
183
+ if !acceptsLint && !acceptsQuickFix && !acceptsFormat {
131
184
  return writeJSON([]lspCodeAction{})
132
185
  }
133
186
  if lspProjectTargetHasSegment(opts, "node_modules") {
@@ -143,9 +196,13 @@ func RunLSPCodeActions(args []string) int {
143
196
  if code != 0 {
144
197
  return code
145
198
  }
199
+ findings = filterFindingsForPath(findings, mustFilePathFromURI(opts.uri))
146
200
  lintFindings := filterLintFindings(findings)
147
201
  formatFindings := filterFormatFindings(findings)
148
202
  var actions []lspCodeAction
203
+ if acceptsQuickFix {
204
+ actions = append(actions, lspSuggestionCodeActions(opts, lintFindings, quickFixRange)...)
205
+ }
149
206
  if acceptsLint && hasFixableFinding(lintFindings) {
150
207
  uriArg, _ := json.Marshal(opts.uri)
151
208
  actions = append(actions, lspCodeAction{
@@ -180,7 +237,9 @@ func RunLSPExecuteCommand(args []string) int {
180
237
  if !ok {
181
238
  return 2
182
239
  }
183
- if opts.command != commandLintFixAll && opts.command != commandFormatDocument {
240
+ if opts.command != commandLintFixAll &&
241
+ opts.command != commandLintApplySuggestion &&
242
+ opts.command != commandFormatDocument {
184
243
  fmt.Fprintf(os.Stderr, "@ttsc/lint lsp-execute-command: unknown command %q\n", opts.command)
185
244
  return 2
186
245
  }
@@ -190,6 +249,13 @@ func RunLSPExecuteCommand(args []string) int {
190
249
  return 2
191
250
  }
192
251
  opts.uri = uri
252
+ if opts.command == commandLintApplySuggestion {
253
+ edit, code := lspWorkspaceEditForSuggestion(opts)
254
+ if code != 0 {
255
+ return code
256
+ }
257
+ return writeJSON(edit)
258
+ }
193
259
  // --content-stdin selects the lightweight in-memory format path: the full
194
260
  // document buffer is read from stdin and formatted with AST+source rules
195
261
  // only, with no temp-workspace copy and no tsgo Program. It applies to
@@ -221,6 +287,7 @@ func parseLSPCommandOptions(name string, args []string) (*lspCommandOptions, boo
221
287
  cwd := fs.String("cwd", "", "")
222
288
  tsconfig := fs.String("tsconfig", "tsconfig.json", "")
223
289
  pluginsJSON := fs.String("plugins-json", "", "")
290
+ projectContextJSON := fs.String("project-context-json", "", "")
224
291
  uri := fs.String("uri", "", "")
225
292
  rangeJSON := fs.String("range-json", "", "")
226
293
  contextJSON := fs.String("context-json", "", "")
@@ -235,16 +302,22 @@ func parseLSPCommandOptions(name string, args []string) (*lspCommandOptions, boo
235
302
  fmt.Fprintln(os.Stderr, err)
236
303
  return nil, false
237
304
  }
305
+ projectIdentity, err := decodeProjectIdentity(*projectContextJSON)
306
+ if err != nil {
307
+ fmt.Fprintln(os.Stderr, err)
308
+ return nil, false
309
+ }
238
310
  return &lspCommandOptions{
239
- argumentsJSON: *argumentsJSON,
240
- command: *command,
241
- contextJSON: *contextJSON,
242
- contentStdin: *contentStdin,
243
- cwd: resolvedCwd,
244
- pluginsJSON: *pluginsJSON,
245
- rangeJSON: *rangeJSON,
246
- tsconfig: *tsconfig,
247
- uri: *uri,
311
+ argumentsJSON: *argumentsJSON,
312
+ command: *command,
313
+ contextJSON: *contextJSON,
314
+ contentStdin: *contentStdin,
315
+ cwd: resolvedCwd,
316
+ pluginsJSON: *pluginsJSON,
317
+ rangeJSON: *rangeJSON,
318
+ tsconfig: *tsconfig,
319
+ uri: *uri,
320
+ projectIdentity: projectIdentity,
248
321
  }, true
249
322
  }
250
323
 
@@ -258,18 +331,26 @@ func lspFindings(opts *lspCommandOptions, includeFormatDefaults bool) ([]*Findin
258
331
  fmt.Fprintln(os.Stderr, err)
259
332
  return nil, nil, nil, 2
260
333
  }
261
- rules, err := loadRules(opts.pluginsJSON, opts.cwd, opts.tsconfig)
334
+ rules, err := loadLSPCommandRules(
335
+ opts.pluginsJSON,
336
+ opts.cwd,
337
+ opts.tsconfig,
338
+ target,
339
+ includeFormatDefaults,
340
+ )
262
341
  if err != nil {
263
342
  fmt.Fprintln(os.Stderr, err)
264
343
  return nil, nil, nil, 2
265
344
  }
266
- if includeFormatDefaults {
267
- rules = formatCommandResolver{inner: rules}
268
- }
269
345
  engine := NewEngineWithResolver(rules)
346
+ if err := engine.ConfigError(); err != nil {
347
+ fmt.Fprintln(os.Stderr, err)
348
+ return nil, nil, nil, 2
349
+ }
270
350
  prog, parseDiags, err := loadProgram(opts.cwd, opts.tsconfig, loadProgramOptions{
271
351
  forceNoEmit: true,
272
352
  needsRuleChecker: engine.NeedsTypeChecker(),
353
+ projectIdentity: opts.projectIdentity,
273
354
  })
274
355
  if err != nil {
275
356
  fmt.Fprintf(os.Stderr, "@ttsc/lint: %v\n", err)
@@ -279,18 +360,53 @@ func lspFindings(opts *lspCommandOptions, includeFormatDefaults bool) ([]*Findin
279
360
  // tsgo already owns parse diagnostics in the upstream LSP process.
280
361
  return nil, prog, prog.close, 0
281
362
  }
282
- findings := filterFindingsForPath(engine.Run(prog.userSourceFiles(), prog.checker), target)
363
+ findings := prog.runLintCycle(engine)
283
364
  return findings, prog, prog.close, 0
284
365
  }
285
366
 
367
+ // loadLSPCommandRules constructs the resolver shared by every LSP command
368
+ // path. Format requests use the same missing-config fallback, documented
369
+ // defaults, editor overrides, and language precedence as the CLI formatter and
370
+ // in-memory buffer formatter; lint-only requests retain the strict lint loader.
371
+ // formatTarget is the real editor document even when cwd/tsconfig point at the
372
+ // temporary workspace used by the disk execute-command path.
373
+ func loadLSPCommandRules(
374
+ pluginsJSON string,
375
+ cwd string,
376
+ tsconfigPath string,
377
+ formatTarget string,
378
+ includeFormatDefaults bool,
379
+ ) (RuleResolver, error) {
380
+ if !includeFormatDefaults {
381
+ return loadRules(pluginsJSON, cwd, tsconfigPath)
382
+ }
383
+ rules, err := loadFormatRules(pluginsJSON, cwd, tsconfigPath)
384
+ if err != nil {
385
+ return nil, err
386
+ }
387
+ return newFormatCommandResolver(
388
+ rules,
389
+ filepath.Dir(formatTarget),
390
+ vscodeLanguageID(formatTarget),
391
+ )
392
+ }
393
+
394
+ func mustFilePathFromURI(uri string) string {
395
+ target, err := filePathFromURI(uri)
396
+ if err != nil {
397
+ return ""
398
+ }
399
+ return target
400
+ }
401
+
286
402
  func filterFindingsForPath(findings []*Finding, target string) []*Finding {
287
- target = canonicalProjectPath("", target)
403
+ target = canonicalProjectPath("", realProjectPath(target))
288
404
  out := make([]*Finding, 0, len(findings))
289
405
  for _, finding := range findings {
290
406
  if finding == nil || finding.File == nil {
291
407
  continue
292
408
  }
293
- if canonicalProjectPath("", finding.File.FileName()) == target {
409
+ if canonicalProjectPath("", realProjectPath(finding.File.FileName())) == target {
294
410
  out = append(out, finding)
295
411
  }
296
412
  }
@@ -366,24 +482,267 @@ func acceptsActionKind(raw string, kind string) bool {
366
482
  return false
367
483
  }
368
484
 
369
- func lspWorkspaceEditForCommand(opts *lspCommandOptions) (*lspWorkspaceEdit, int) {
485
+ func lspSuggestionCodeActions(
486
+ opts *lspCommandOptions,
487
+ findings []*Finding,
488
+ requestedRange lspRange,
489
+ ) []lspCodeAction {
490
+ actions := make([]lspCodeAction, 0)
491
+ sourceHashes := make(map[*shimast.SourceFile]string)
492
+ for _, finding := range findings {
493
+ if finding == nil || finding.File == nil || len(finding.Suggestions) == 0 {
494
+ continue
495
+ }
496
+ if !lspRangesOverlap(requestedRange, lspRangeForFinding(finding)) {
497
+ continue
498
+ }
499
+ sourceHash, ok := sourceHashes[finding.File]
500
+ if !ok {
501
+ sourceHash = lspSourceHash(finding.File.Text())
502
+ sourceHashes[finding.File] = sourceHash
503
+ }
504
+ for index, suggestion := range finding.Suggestions {
505
+ if suggestion.Title == "" || len(suggestion.Edits) == 0 {
506
+ continue
507
+ }
508
+ uriArg, err := json.Marshal(opts.uri)
509
+ if err != nil {
510
+ continue
511
+ }
512
+ selectionArg, err := json.Marshal(lspSuggestionSelection{
513
+ Rule: finding.Rule,
514
+ Message: finding.Message,
515
+ Pos: finding.Pos,
516
+ End: finding.End,
517
+ SuggestionIndex: index,
518
+ Title: suggestion.Title,
519
+ SourceHash: sourceHash,
520
+ Fingerprint: lspSuggestionFingerprint(finding, suggestion),
521
+ })
522
+ if err != nil {
523
+ continue
524
+ }
525
+ actions = append(actions, lspCodeAction{
526
+ Title: suggestion.Title,
527
+ Kind: "quickfix.ttsc",
528
+ Command: &lspCommand{
529
+ Title: suggestion.Title,
530
+ Command: commandLintApplySuggestion,
531
+ Arguments: []json.RawMessage{uriArg, selectionArg},
532
+ },
533
+ })
534
+ }
535
+ }
536
+ return actions
537
+ }
538
+
539
+ func parseRequestedLSPRange(raw string) (lspRange, bool) {
540
+ var wire lspRangeWire
541
+ if strings.TrimSpace(raw) == "" || json.Unmarshal([]byte(raw), &wire) != nil ||
542
+ wire.Start == nil || wire.End == nil ||
543
+ wire.Start.Line == nil || wire.Start.Character == nil ||
544
+ wire.End.Line == nil || wire.End.Character == nil {
545
+ return lspRange{}, false
546
+ }
547
+ requested := lspRange{
548
+ Start: lspPosition{Line: *wire.Start.Line, Character: *wire.Start.Character},
549
+ End: lspPosition{Line: *wire.End.Line, Character: *wire.End.Character},
550
+ }
551
+ if requested.Start.Line < 0 || requested.Start.Character < 0 ||
552
+ requested.End.Line < 0 || requested.End.Character < 0 ||
553
+ compareLSPPositions(requested.Start, requested.End) > 0 {
554
+ return lspRange{}, false
555
+ }
556
+ return requested, true
557
+ }
558
+
559
+ func lspRangesOverlap(left lspRange, right lspRange) bool {
560
+ if compareLSPPositions(left.Start, left.End) == 0 {
561
+ return compareLSPPositions(left.Start, right.Start) >= 0 &&
562
+ compareLSPPositions(left.Start, right.End) < 0
563
+ }
564
+ if compareLSPPositions(right.Start, right.End) == 0 {
565
+ return compareLSPPositions(right.Start, left.Start) >= 0 &&
566
+ compareLSPPositions(right.Start, left.End) < 0
567
+ }
568
+ return compareLSPPositions(left.Start, right.End) < 0 &&
569
+ compareLSPPositions(right.Start, left.End) < 0
570
+ }
571
+
572
+ func compareLSPPositions(left lspPosition, right lspPosition) int {
573
+ if left.Line < right.Line {
574
+ return -1
575
+ }
576
+ if left.Line > right.Line {
577
+ return 1
578
+ }
579
+ if left.Character < right.Character {
580
+ return -1
581
+ }
582
+ if left.Character > right.Character {
583
+ return 1
584
+ }
585
+ return 0
586
+ }
587
+
588
+ func lspSourceHash(source string) string {
589
+ return fmt.Sprintf("%x", sha256.Sum256([]byte(source)))
590
+ }
591
+
592
+ func lspSuggestionFingerprint(finding *Finding, suggestion Suggestion) string {
593
+ payload, _ := json.Marshal(struct {
594
+ Rule string `json:"rule"`
595
+ Message string `json:"message"`
596
+ Pos int `json:"pos"`
597
+ End int `json:"end"`
598
+ Title string `json:"title"`
599
+ Edits []TextEdit `json:"edits"`
600
+ }{
601
+ Rule: finding.Rule,
602
+ Message: finding.Message,
603
+ Pos: finding.Pos,
604
+ End: finding.End,
605
+ Title: suggestion.Title,
606
+ Edits: suggestion.Edits,
607
+ })
608
+ return fmt.Sprintf("%x", sha256.Sum256(payload))
609
+ }
610
+
611
+ func lspWorkspaceEditForSuggestion(opts *lspCommandOptions) (*lspWorkspaceEdit, int) {
370
612
  target, err := filePathFromURI(opts.uri)
371
613
  if err != nil {
372
614
  fmt.Fprintln(os.Stderr, err)
373
615
  return nil, 2
374
616
  }
375
- if _, ok := projectRelativePath(opts.cwd, target); !ok {
376
- fmt.Fprintf(os.Stderr, "@ttsc/lint: LSP command target %s is outside cwd %s\n", target, opts.cwd)
617
+ physicalCwd := realProjectPath(opts.cwd)
618
+ physicalTarget := realProjectPath(target)
619
+ if _, ok := projectRelativePath(physicalCwd, physicalTarget); !ok {
620
+ fmt.Fprintf(os.Stderr, "@ttsc/lint: LSP command target %s is outside cwd %s\n", physicalTarget, physicalCwd)
621
+ return nil, 2
622
+ }
623
+ if lspProjectTargetHasSegment(opts, "node_modules") {
624
+ return nil, 0
625
+ }
626
+ selection, err := suggestionSelectionArgument(opts.argumentsJSON)
627
+ if err != nil {
628
+ fmt.Fprintln(os.Stderr, err)
377
629
  return nil, 2
378
630
  }
379
- if projectPathHasSegment(opts.cwd, target, "node_modules") {
631
+ current, err := os.ReadFile(physicalTarget)
632
+ if err != nil || lspSourceHash(string(current)) != selection.SourceHash {
380
633
  return nil, 0
381
634
  }
635
+ findings, _, closeProgram, code := lspFindings(opts, false)
636
+ if closeProgram != nil {
637
+ defer closeProgram()
638
+ }
639
+ if code != 0 {
640
+ return nil, code
641
+ }
642
+ findings = filterFindingsForPath(findings, physicalTarget)
643
+ for _, finding := range findings {
644
+ if finding == nil || finding.Rule != selection.Rule ||
645
+ finding.Message != selection.Message ||
646
+ finding.Pos != selection.Pos || finding.End != selection.End ||
647
+ selection.SuggestionIndex >= len(finding.Suggestions) {
648
+ continue
649
+ }
650
+ suggestion := finding.Suggestions[selection.SuggestionIndex]
651
+ if suggestion.Title != selection.Title || finding.File == nil ||
652
+ lspSourceHash(finding.File.Text()) != selection.SourceHash ||
653
+ lspSuggestionFingerprint(finding, suggestion) != selection.Fingerprint {
654
+ continue
655
+ }
656
+ source := finding.File.Text()
657
+ selected := selectTextEdits(len(source), suggestion.Edits)
658
+ if len(selected) == 0 || len(selected) != len(suggestion.Edits) {
659
+ return nil, 0
660
+ }
661
+ edits := make([]lspTextEdit, 0, len(selected))
662
+ for _, edit := range selected {
663
+ edits = append(edits, lspTextEdit{
664
+ Range: lspRange{
665
+ Start: byteOffsetToLSPPosition(source, edit.Pos),
666
+ End: byteOffsetToLSPPosition(source, edit.End),
667
+ },
668
+ NewText: edit.Text,
669
+ })
670
+ }
671
+ current, err := os.ReadFile(physicalTarget)
672
+ if err != nil || lspSourceHash(string(current)) != selection.SourceHash {
673
+ return nil, 0
674
+ }
675
+ return &lspWorkspaceEdit{Changes: map[string][]lspTextEdit{opts.uri: edits}}, 0
676
+ }
677
+ return nil, 0
678
+ }
679
+
680
+ func suggestionSelectionArgument(raw string) (lspSuggestionSelection, error) {
681
+ var args []json.RawMessage
682
+ if strings.TrimSpace(raw) == "" || json.Unmarshal([]byte(raw), &args) != nil || len(args) < 2 {
683
+ return lspSuggestionSelection{}, errors.New("@ttsc/lint lsp-execute-command: missing suggestion selection argument")
684
+ }
685
+ var selection lspSuggestionSelection
686
+ if err := json.Unmarshal(args[1], &selection); err != nil {
687
+ return lspSuggestionSelection{}, fmt.Errorf("@ttsc/lint lsp-execute-command: invalid suggestion selection: %w", err)
688
+ }
689
+ if selection.Rule == "" || selection.Message == "" || selection.Title == "" ||
690
+ len(selection.SourceHash) != sha256.Size*2 ||
691
+ len(selection.Fingerprint) != sha256.Size*2 ||
692
+ selection.Pos < 0 || selection.End < selection.Pos || selection.SuggestionIndex < 0 {
693
+ return lspSuggestionSelection{}, errors.New("@ttsc/lint lsp-execute-command: invalid suggestion selection")
694
+ }
695
+ return selection, nil
696
+ }
697
+
698
+ func lspWorkspaceEditForCommand(opts *lspCommandOptions) (*lspWorkspaceEdit, int) {
699
+ target, err := filePathFromURI(opts.uri)
700
+ if err != nil {
701
+ fmt.Fprintln(os.Stderr, err)
702
+ return nil, 2
703
+ }
704
+ if lspProjectTargetHasSegment(opts, "node_modules") {
705
+ return nil, 0
706
+ }
707
+ // Containment is the only physical guard: resolve both sides so a symlinked
708
+ // or short-name spelling of either cannot smuggle the target outside cwd.
709
+ // `target` itself stays in its LOGICAL (URI) spelling for everything that
710
+ // indexes into the temp workspace below — the temp tree materializes a
711
+ // project-internal symlink/junction under its logical name, so resolving the
712
+ // target to its physical destination here would address a path the temp tree
713
+ // never created (samchon/ttsc#614).
714
+ physicalCwd := realProjectPath(opts.cwd)
715
+ physicalTarget := realProjectPath(target)
716
+ if _, ok := projectRelativePath(physicalCwd, physicalTarget); !ok {
717
+ fmt.Fprintf(os.Stderr, "@ttsc/lint: LSP command target %s is outside cwd %s\n", physicalTarget, physicalCwd)
718
+ return nil, 2
719
+ }
382
720
  original, err := os.ReadFile(target)
383
721
  if err != nil {
384
722
  fmt.Fprintf(os.Stderr, "@ttsc/lint: read %s: %v\n", target, err)
385
723
  return nil, 2
386
724
  }
725
+ return lspWorkspaceEditForSeededCommand(opts, target, string(original), nil)
726
+ }
727
+
728
+ // lspWorkspaceEditForSeededCommand runs a command in the ordinary temporary
729
+ // project while optionally replacing the target copy with editor-owned text.
730
+ // original is always the document against which the returned full-document
731
+ // edit is measured. A non-nil sourceOverlay is written only inside the
732
+ // temporary workspace, so type-aware contributor rules receive a real Program
733
+ // and Checker for the dirty document without using or mutating its disk twin as
734
+ // command input. Dirty buffers with syntax errors fail closed before any fix is
735
+ // applied.
736
+ func lspWorkspaceEditForSeededCommand(
737
+ opts *lspCommandOptions,
738
+ target string,
739
+ original string,
740
+ sourceOverlay *string,
741
+ ) (*lspWorkspaceEdit, int) {
742
+ // Pass the LOGICAL cwd/target so the temp workspace is indexed by the
743
+ // project's logical layout (a project-internal symlink or junction keeps its
744
+ // logical name). Physical resolution stays inside prepareLSPCommandWorkspace
745
+ // for the copy source and inside tempPathFor's boundary-alias fallback.
387
746
  tempRoot, tempTarget, tempTsconfig, cleanup, err := prepareLSPCommandWorkspace(opts.cwd, opts.tsconfig, target)
388
747
  if err != nil {
389
748
  fmt.Fprintln(os.Stderr, err)
@@ -391,16 +750,34 @@ func lspWorkspaceEditForCommand(opts *lspCommandOptions) (*lspWorkspaceEdit, int
391
750
  }
392
751
  defer cleanup()
393
752
 
753
+ if sourceOverlay != nil {
754
+ if err := os.MkdirAll(filepath.Dir(tempTarget), 0o755); err != nil {
755
+ fmt.Fprintf(os.Stderr, "@ttsc/lint: create dirty-buffer directory: %v\n", err)
756
+ return nil, 2
757
+ }
758
+ if err := os.WriteFile(tempTarget, []byte(*sourceOverlay), 0o600); err != nil {
759
+ fmt.Fprintf(os.Stderr, "@ttsc/lint: seed dirty buffer %s: %v\n", target, err)
760
+ return nil, 2
761
+ }
762
+ }
763
+
394
764
  pluginsJSON := remapLSPPluginsJSONForTempWorkspace(opts.pluginsJSON, opts.cwd, tempRoot)
395
- rules, err := loadRules(pluginsJSON, tempRoot, tempTsconfig)
765
+ rules, err := loadLSPCommandRules(
766
+ pluginsJSON,
767
+ tempRoot,
768
+ tempTsconfig,
769
+ target,
770
+ opts.command == commandFormatDocument,
771
+ )
396
772
  if err != nil {
397
773
  fmt.Fprintln(os.Stderr, err)
398
774
  return nil, 2
399
775
  }
400
- if opts.command == commandFormatDocument {
401
- rules = formatCommandResolver{inner: rules}
402
- }
403
776
  engine := NewEngineWithResolver(rules)
777
+ if err := engine.ConfigError(); err != nil {
778
+ fmt.Fprintln(os.Stderr, err)
779
+ return nil, 2
780
+ }
404
781
  needsRuleChecker := engine.NeedsTypeChecker()
405
782
  maxPasses := maxFixPasses
406
783
  if opts.command == commandFormatDocument {
@@ -411,6 +788,7 @@ func lspWorkspaceEditForCommand(opts *lspCommandOptions) (*lspWorkspaceEdit, int
411
788
  prog, parseDiags, err := loadProgram(tempRoot, tempTsconfig, loadProgramOptions{
412
789
  forceNoEmit: true,
413
790
  needsRuleChecker: needsRuleChecker,
791
+ projectIdentity: opts.projectIdentity,
414
792
  })
415
793
  if err != nil {
416
794
  fmt.Fprintf(os.Stderr, "@ttsc/lint: %v\n", err)
@@ -420,7 +798,27 @@ func lspWorkspaceEditForCommand(opts *lspCommandOptions) (*lspWorkspaceEdit, int
420
798
  prog.close()
421
799
  return nil, 0
422
800
  }
423
- findings := filterFindingsForPath(engine.Run(prog.userSourceFiles(), prog.checker), tempTarget)
801
+ // The command target must resolve to a source file the temp program loaded.
802
+ // A nil here means the temp workspace did not materialize the target under
803
+ // the spelling the tsconfig references — the signature of the symlink/
804
+ // junction copy regression. Fail loud (exit 2) instead of returning a silent
805
+ // nil edit the editor cannot distinguish from an already-clean document.
806
+ targetFile := prog.findSourceFile(tempTarget)
807
+ if targetFile == nil {
808
+ prog.close()
809
+ fmt.Fprintf(os.Stderr,
810
+ "@ttsc/lint: LSP %s: target source file %s is not in the program\n",
811
+ opts.command, tempTarget)
812
+ return nil, 2
813
+ }
814
+ if sourceOverlay != nil &&
815
+ len(prog.tsProgram.GetSyntacticDiagnostics(context.Background(), targetFile)) > 0 {
816
+ // A dirty overlay buffer with syntax errors is a benign no-op: don't fight
817
+ // the editor's own diagnostics on the dirty document with a partial fix.
818
+ prog.close()
819
+ return nil, 0
820
+ }
821
+ findings := filterFindingsForPath(prog.runLintCycle(engine), tempTarget)
424
822
  prog.close()
425
823
  if opts.command == commandFormatDocument {
426
824
  findings = filterFormatFindings(findings)
@@ -448,18 +846,18 @@ func lspWorkspaceEditForCommand(opts *lspCommandOptions) (*lspWorkspaceEdit, int
448
846
  fmt.Fprintf(os.Stderr, "@ttsc/lint: read cascaded %s: %v\n", tempTarget, err)
449
847
  return nil, 2
450
848
  }
451
- return workspaceEditForFullDocument(opts.uri, string(original), string(next)), 0
849
+ return workspaceEditForFullDocument(opts.uri, original, string(next)), 0
452
850
  }
453
851
 
454
852
  // lspFormatBuffer formats an in-memory document buffer using only the
455
- // format-class rules, with no tsgo Program and no temp-workspace copy. It is
456
- // the lightweight path behind --content-stdin for ttsc.format.document.
853
+ // format-class rules. It is the path behind --content-stdin for
854
+ // ttsc.format.document.
457
855
  //
458
- // Format rules are AST+source only (`IsFormat() == true`); none implement
459
- // typeAwareRule, so the engine runs them with a nil checker. The document
460
- // content comes entirely from `content` the file on disk at opts.uri is
461
- // never read so an editor can format an unsaved buffer without paying the
462
- // disk-copy + full-program-load cost of lspWorkspaceEditForCommand.
856
+ // Built-in format rules are AST+source only and use the lightweight single-file
857
+ // parser below. Contributor format rules conservatively require a checker, so
858
+ // they use a temporary project seeded with `content`. Both branches derive
859
+ // findings and edits from the supplied buffer; neither uses stale disk content
860
+ // as the document or mutates the original project.
463
861
  //
464
862
  // Returns the same WorkspaceEdit shape as the disk path, or (nil, 0) on a
465
863
  // no-op (no fixable findings, or text unchanged after convergence).
@@ -471,32 +869,47 @@ func lspFormatBuffer(content string, opts *lspCommandOptions) (*lspWorkspaceEdit
471
869
  }
472
870
  // Guard rails mirror lspWorkspaceEditForCommand: skip targets outside the
473
871
  // project root or inside node_modules.
474
- if _, ok := projectRelativePath(opts.cwd, target); !ok {
475
- fmt.Fprintf(os.Stderr, "@ttsc/lint: LSP command target %s is outside cwd %s\n", target, opts.cwd)
872
+ physicalCwd := realProjectPath(opts.cwd)
873
+ physicalTarget := realProjectPath(target)
874
+ if _, ok := projectRelativePath(physicalCwd, physicalTarget); !ok {
875
+ fmt.Fprintf(os.Stderr, "@ttsc/lint: LSP command target %s is outside cwd %s\n", physicalTarget, physicalCwd)
476
876
  return nil, 2
477
877
  }
478
- if projectPathHasSegment(opts.cwd, target, "node_modules") {
878
+ if lspProjectTargetHasSegment(opts, "node_modules") {
479
879
  return nil, 0
480
880
  }
481
881
 
482
- rules, err := loadFormatRules(opts.pluginsJSON, opts.cwd, opts.tsconfig)
882
+ resolver, err := loadLSPCommandRules(
883
+ opts.pluginsJSON,
884
+ opts.cwd,
885
+ opts.tsconfig,
886
+ target,
887
+ true,
888
+ )
483
889
  if err != nil {
484
890
  fmt.Fprintln(os.Stderr, err)
485
891
  return nil, 2
486
892
  }
487
- resolver, err := newFormatCommandResolver(rules, filepath.Dir(target), vscodeLanguageID(target))
488
- if err != nil {
893
+ engine := NewEngineWithResolver(resolver)
894
+ if err := engine.ConfigError(); err != nil {
489
895
  fmt.Fprintln(os.Stderr, err)
490
896
  return nil, 2
491
897
  }
492
- engine := NewEngineWithResolver(resolver)
493
898
  if engine.NeedsTypeChecker() {
494
- // A format-class contributor rule (formatContributorAdapter) needs the type
495
- // checker, which the single-file in-memory parse can't supply. Fall back to
496
- // the disk path, which builds a full program with a checker, so the result
497
- // matches `ttsc format`; the dirty buffer can't be honored for these rules.
498
- // Built-in format rules are AST-only, so they keep the fast in-memory path.
499
- return lspWorkspaceEditForCommand(opts)
899
+ // Contributor format rules conservatively require a checker. Seed the
900
+ // dirty text into the temporary project so the checker, findings, fix
901
+ // ranges, and final WorkspaceEdit all describe the same editor document.
902
+ // Built-in AST-only format rules keep the fast single-file path below.
903
+ // Pass the LOGICAL target: the seeded command indexes it into the temp
904
+ // workspace (which mirrors the project's logical layout), so a
905
+ // project-internal symlink/junction must keep its logical name here too.
906
+ sourceOverlay := content
907
+ return lspWorkspaceEditForSeededCommand(
908
+ opts,
909
+ target,
910
+ content,
911
+ &sourceOverlay,
912
+ )
500
913
  }
501
914
  scriptKind := scriptKindForPath(target)
502
915
 
@@ -546,21 +959,23 @@ func scriptKindForPath(path string) shimcore.ScriptKind {
546
959
  }
547
960
 
548
961
  // applyFindingFixesToText is the in-memory counterpart of
549
- // applyFindingFixes/applyTextEditsToFile (fix.go): it collects every fixable
550
- // finding's TextEdit, selects a non-overlapping set with the same
551
- // selectTextEdits logic, applies them right-to-left to `text`, and returns the
552
- // new string plus the number of edits applied. It never writes to disk and
553
- // never reloads a Program. Findings carry byte offsets into the same `text`
554
- // that was just parsed, so no per-file grouping is needed.
962
+ // applyFindingFixes/applyTextEditsToFile (fix.go): it groups every fixable
963
+ // finding's edits, selects a non-overlapping, per-finding-atomic set with the
964
+ // same selectTextEditGroups logic, applies them right-to-left to `text`, and
965
+ // returns the new string plus the number of edits applied. It never writes to
966
+ // disk and never reloads a Program. Findings carry byte offsets into the same
967
+ // `text` that was just parsed, so no per-file grouping is needed — but each
968
+ // finding still forms its own atomic edit group so a partially-overlapped
969
+ // multi-edit fix is dropped whole rather than half-applied (samchon/ttsc#605).
555
970
  func applyFindingFixesToText(text string, findings []*Finding) (string, int) {
556
- edits := make([]TextEdit, 0, len(findings))
971
+ groups := make([][]TextEdit, 0, len(findings))
557
972
  for _, finding := range findings {
558
973
  if finding == nil || len(finding.Fix) == 0 {
559
974
  continue
560
975
  }
561
- edits = append(edits, finding.Fix...)
976
+ groups = append(groups, finding.Fix)
562
977
  }
563
- selected := selectTextEdits(len(text), edits)
978
+ selected := selectTextEditGroups(len(text), groups)
564
979
  if len(selected) == 0 {
565
980
  return text, 0
566
981
  }
@@ -598,11 +1013,15 @@ func prepareLSPCommandWorkspace(cwd string, tsconfig string, target string) (str
598
1013
  cleanup()
599
1014
  return "", "", "", nil, fmt.Errorf("@ttsc/lint: LSP command target %s is outside cwd %s", target, cwd)
600
1015
  }
601
- if err := copyLSPCommandWorkspace(cwd, tempRoot); err != nil {
1016
+ // Copy from the resolved (physical) cwd so a symlinked or short-name project
1017
+ // root still descends into the real tree; the copy reproduces the project's
1018
+ // LOGICAL layout, which tempPathFor above indexes into by the logical cwd.
1019
+ physicalCwd := realProjectPath(cwd)
1020
+ if err := copyLSPCommandWorkspace(physicalCwd, tempRoot); err != nil {
602
1021
  cleanup()
603
1022
  return "", "", "", nil, err
604
1023
  }
605
- if err := linkNearestNodeModules(tempRoot, cwd); err != nil {
1024
+ if err := linkNearestNodeModules(tempRoot, physicalCwd); err != nil {
606
1025
  cleanup()
607
1026
  return "", "", "", nil, err
608
1027
  }
@@ -633,7 +1052,11 @@ func copyLSPCommandWorkspace(src string, dst string) error {
633
1052
  if entry.IsDir() {
634
1053
  return filepath.SkipDir
635
1054
  }
636
- if entry.Type()&fs.ModeSymlink != 0 {
1055
+ // A skipped directory that is a reparse point (a symlinked or junctioned
1056
+ // node_modules/.git) is dropped without materializing its contents. A
1057
+ // junction reports ModeSymlink on current toolchains and ModeIrregular on
1058
+ // older ones, so check both bits.
1059
+ if entry.Type()&(fs.ModeSymlink|fs.ModeIrregular) != 0 {
637
1060
  return nil
638
1061
  }
639
1062
  }
@@ -652,28 +1075,34 @@ func copyLSPCommandWorkspaceEntry(src string, dst string, seenDirs map[string]st
652
1075
  if err != nil {
653
1076
  return err
654
1077
  }
655
- isSymlink := linkInfo.Mode()&os.ModeSymlink != 0
656
- if isSymlink {
657
- realDir, err := filepath.EvalSymlinks(src)
658
- if err == nil {
659
- if _, ok := seenDirs[realDir]; ok {
660
- return nil
661
- }
662
- // Track the real path only for the active recursion branch.
663
- // Releasing it on return lets sibling aliases pointing at the
664
- // same real directory (e.g. `src-a -> real-src` and
665
- // `src-b -> real-src` in different tsconfig entries) each get
666
- // materialized; the test
667
- // `TestLSPExecuteCommandMaterializesDuplicateSymlinkedDirectories`
668
- // pins this contract.
669
- seenDirs[realDir] = struct{}{}
670
- defer delete(seenDirs, realDir)
1078
+ // A directory that is itself a reparse point — a symlink OR an NTFS
1079
+ // junction — is not descended into by filepath.WalkDir, so its contents
1080
+ // must be materialized here under the LOGICAL name. Go reports a junction as
1081
+ // ModeSymlink on current toolchains but as ModeIrregular on older ones;
1082
+ // treat either bit as a link so the copy is correct regardless of version.
1083
+ // A plain directory (neither bit) is created empty and left for WalkDir to
1084
+ // descend (samchon/ttsc#614).
1085
+ isLink := linkInfo.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0
1086
+ if isLink {
1087
+ // resolveDirLink chases the symlink/junction via os.Readlink, which
1088
+ // resolves junctions that neither ModeSymlink nor EvalSymlinks expose on
1089
+ // older toolchains. The real path keys the cycle guard, scoped to the
1090
+ // active recursion branch (defer delete) so sibling aliases pointing at
1091
+ // one real directory (e.g. `src-a -> real-src` and `src-b -> real-src` in
1092
+ // different tsconfig entries) each get materialized; the test
1093
+ // `TestLSPExecuteCommandMaterializesDuplicateSymlinkedDirectories` pins
1094
+ // this contract.
1095
+ realDir := resolveDirLink(src)
1096
+ if _, ok := seenDirs[realDir]; ok {
1097
+ return nil
671
1098
  }
1099
+ seenDirs[realDir] = struct{}{}
1100
+ defer delete(seenDirs, realDir)
672
1101
  }
673
1102
  if err := os.MkdirAll(dst, mode.Perm()); err != nil {
674
1103
  return err
675
1104
  }
676
- if !isSymlink {
1105
+ if !isLink {
677
1106
  return nil
678
1107
  }
679
1108
  entries, err := os.ReadDir(src)
@@ -741,7 +1170,11 @@ func lspProjectTargetHasSegment(opts *lspCommandOptions, segment string) bool {
741
1170
  if err != nil {
742
1171
  return false
743
1172
  }
744
- return projectPathHasSegment(opts.cwd, target, segment)
1173
+ if opts.projectIdentity.LogicalProjectRoot != "" &&
1174
+ projectPathHasSegment(opts.projectIdentity.LogicalProjectRoot, target, segment) {
1175
+ return true
1176
+ }
1177
+ return projectPathHasSegment(realProjectPath(opts.cwd), realProjectPath(target), segment)
745
1178
  }
746
1179
 
747
1180
  func lspProjectTargetOutsideCwd(opts *lspCommandOptions) bool {
@@ -752,7 +1185,7 @@ func lspProjectTargetOutsideCwd(opts *lspCommandOptions) bool {
752
1185
  if err != nil {
753
1186
  return false
754
1187
  }
755
- _, ok := projectRelativePath(opts.cwd, target)
1188
+ _, ok := projectRelativePath(realProjectPath(opts.cwd), realProjectPath(target))
756
1189
  return !ok
757
1190
  }
758
1191
 
@@ -791,9 +1224,23 @@ func remapLSPPluginsJSONForTempWorkspace(raw string, cwd string, tempRoot string
791
1224
  return string(next)
792
1225
  }
793
1226
 
1227
+ // tempPathFor maps a project `file` to its location inside the temp workspace,
1228
+ // which mirrors the project's LOGICAL layout. It therefore prefers the logical
1229
+ // relative path: a project-internal symlink or junction (src -> real-src),
1230
+ // materialized by copyLSPCommandWorkspace under its logical name, must be
1231
+ // addressed the same way here — resolving `file` physically would point at a
1232
+ // spelling (real-src/main.ts) the temp tree never created (samchon/ttsc#614).
1233
+ // Only when cwd and file don't share a logical prefix — an incidental boundary
1234
+ // alias such as a Windows 8.3 short cwd or macOS /tmp -> /private/tmp, where the
1235
+ // two spellings can independently pick either alias — does it fall back to
1236
+ // realProjectPath to reconcile them. Containment guards resolve physically and
1237
+ // live at the call sites, not here.
794
1238
  func tempPathFor(cwd string, tempRoot string, file string) (string, bool) {
795
- rel, err := filepath.Rel(cwd, file)
796
- if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
1239
+ if rel, ok := projectRelativePath(cwd, file); ok {
1240
+ return filepath.Join(tempRoot, rel), true
1241
+ }
1242
+ rel, ok := projectRelativePath(realProjectPath(cwd), realProjectPath(file))
1243
+ if !ok {
797
1244
  return "", false
798
1245
  }
799
1246
  return filepath.Join(tempRoot, rel), true