@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
@@ -0,0 +1,222 @@
1
+ // Grapheme-cluster string length, mirroring typescript-eslint's
2
+ // `getStringLength` utility. Directive descriptions are measured as Unicode
3
+ // extended grapheme clusters, the same units exposed by `Intl.Segmenter` with
4
+ // `granularity: "grapheme"`, rather than bytes, UTF-16 code units, or runes.
5
+ //
6
+ // The implementation follows the ordered extended-cluster rules in UAX #29.
7
+ // Its generated property tables are pinned to Unicode 16.0.0, the Unicode
8
+ // version used by the Node 24 runtime in this repository's CI. Regenerate the
9
+ // tables and official conformance corpus together when that runtime advances.
10
+ package linthost
11
+
12
+ //go:generate go run ../tools/graphemegen -root ..
13
+
14
+ // stringLength returns the number of Unicode extended grapheme clusters in s.
15
+ func stringLength(s string) int {
16
+ return graphemeCount(s)
17
+ }
18
+
19
+ // graphemeCount applies UAX #29's ordered boundary rules in one pass. The
20
+ // segmenter carries only the left contexts required by GB9c, GB11, and
21
+ // GB12/GB13, keeping long combining and regional-indicator runs linear.
22
+ func graphemeCount(s string) int {
23
+ count := 0
24
+ var segmenter graphemeSegmenter
25
+ for _, current := range s {
26
+ properties := graphemeProperties(current)
27
+ if segmenter.hasBoundaryBefore(properties) {
28
+ count++
29
+ }
30
+ segmenter.consume(properties)
31
+ }
32
+ return count
33
+ }
34
+
35
+ type graphemeRuneProperties struct {
36
+ breakClass graphemeBreakClass
37
+ indicConjunctClass indicConjunctBreakClass
38
+ extendedPictographic bool
39
+ }
40
+
41
+ type indicConjunctState uint8
42
+
43
+ const (
44
+ indicConjunctStateNone indicConjunctState = iota
45
+ indicConjunctStateConsonant
46
+ indicConjunctStateLinked
47
+ )
48
+
49
+ type emojiSequenceState uint8
50
+
51
+ const (
52
+ emojiSequenceStateNone emojiSequenceState = iota
53
+ emojiSequenceStatePictographic
54
+ emojiSequenceStateZWJ
55
+ )
56
+
57
+ type graphemeSegmenter struct {
58
+ hasPrevious bool
59
+ previous graphemeRuneProperties
60
+ regionalIndicators int
61
+ indicConjunct indicConjunctState
62
+ emojiSequence emojiSequenceState
63
+ }
64
+
65
+ // hasBoundaryBefore applies GB3 through GB999 in normative order. Start of
66
+ // text is always a boundary (GB1); end of text needs no explicit handling when
67
+ // only the number of clusters is required.
68
+ func (s *graphemeSegmenter) hasBoundaryBefore(current graphemeRuneProperties) bool {
69
+ if !s.hasPrevious {
70
+ return true
71
+ }
72
+ previous := s.previous.breakClass
73
+ next := current.breakClass
74
+
75
+ // GB3: keep CRLF together.
76
+ if previous == graphemeBreakCR && next == graphemeBreakLF {
77
+ return false
78
+ }
79
+ // GB4/GB5: otherwise break before and after controls.
80
+ if isGraphemeControlClass(previous) || isGraphemeControlClass(next) {
81
+ return true
82
+ }
83
+ // GB6-GB8: keep Hangul and other data-defined conjoining sequences.
84
+ if isConjoiningGraphemeJoin(previous, next) {
85
+ return false
86
+ }
87
+ // GB9/GB9a: extending characters, ZWJ, and spacing marks continue the
88
+ // current cluster.
89
+ if next == graphemeBreakExtend || next == graphemeBreakZWJ || next == graphemeBreakSpacingMark {
90
+ return false
91
+ }
92
+ // GB9b: Prepend joins the following non-control character.
93
+ if previous == graphemeBreakPrepend {
94
+ return false
95
+ }
96
+ // GB9c: an Indic consonant-linker sequence joins its next consonant.
97
+ if current.indicConjunctClass == indicConjunctBreakConsonant && s.indicConjunct == indicConjunctStateLinked {
98
+ return false
99
+ }
100
+ // GB11: Extended_Pictographic Extend* ZWJ joins the next pictograph.
101
+ if current.extendedPictographic && s.emojiSequence == emojiSequenceStateZWJ {
102
+ return false
103
+ }
104
+ // GB12/GB13: regional indicators join in pairs. The state is the
105
+ // uninterrupted RI count immediately before this boundary.
106
+ if next == graphemeBreakRegionalIndicator && s.regionalIndicators%2 == 1 {
107
+ return false
108
+ }
109
+ // GB999: all remaining positions are boundaries.
110
+ return true
111
+ }
112
+
113
+ func (s *graphemeSegmenter) consume(current graphemeRuneProperties) {
114
+ if current.breakClass == graphemeBreakRegionalIndicator {
115
+ s.regionalIndicators++
116
+ } else {
117
+ s.regionalIndicators = 0
118
+ }
119
+
120
+ switch current.indicConjunctClass {
121
+ case indicConjunctBreakConsonant:
122
+ s.indicConjunct = indicConjunctStateConsonant
123
+ case indicConjunctBreakExtend:
124
+ // Extend preserves a preceding consonant/linker context.
125
+ case indicConjunctBreakLinker:
126
+ if s.indicConjunct != indicConjunctStateNone {
127
+ s.indicConjunct = indicConjunctStateLinked
128
+ }
129
+ default:
130
+ s.indicConjunct = indicConjunctStateNone
131
+ }
132
+
133
+ switch {
134
+ case current.extendedPictographic:
135
+ s.emojiSequence = emojiSequenceStatePictographic
136
+ case current.breakClass == graphemeBreakExtend && s.emojiSequence == emojiSequenceStatePictographic:
137
+ // Extend preserves the pictograph context immediately before a ZWJ.
138
+ case current.breakClass == graphemeBreakZWJ && s.emojiSequence == emojiSequenceStatePictographic:
139
+ s.emojiSequence = emojiSequenceStateZWJ
140
+ default:
141
+ s.emojiSequence = emojiSequenceStateNone
142
+ }
143
+
144
+ s.previous = current
145
+ s.hasPrevious = true
146
+ }
147
+
148
+ func graphemeProperties(r rune) graphemeRuneProperties {
149
+ return graphemeRuneProperties{
150
+ breakClass: lookupGraphemeBreakClass(r),
151
+ indicConjunctClass: lookupIndicConjunctBreakClass(r),
152
+ extendedPictographic: isExtendedPictographic(r),
153
+ }
154
+ }
155
+
156
+ func lookupGraphemeBreakClass(r rune) graphemeBreakClass {
157
+ lo, hi := 0, len(graphemeBreakRanges)
158
+ for lo < hi {
159
+ middle := int(uint(lo+hi) >> 1)
160
+ candidate := graphemeBreakRanges[middle]
161
+ switch {
162
+ case r < candidate.lo:
163
+ hi = middle
164
+ case r > candidate.hi:
165
+ lo = middle + 1
166
+ default:
167
+ return candidate.class
168
+ }
169
+ }
170
+ return graphemeBreakOther
171
+ }
172
+
173
+ func lookupIndicConjunctBreakClass(r rune) indicConjunctBreakClass {
174
+ lo, hi := 0, len(indicConjunctBreakRanges)
175
+ for lo < hi {
176
+ middle := int(uint(lo+hi) >> 1)
177
+ candidate := indicConjunctBreakRanges[middle]
178
+ switch {
179
+ case r < candidate.lo:
180
+ hi = middle
181
+ case r > candidate.hi:
182
+ lo = middle + 1
183
+ default:
184
+ return candidate.class
185
+ }
186
+ }
187
+ return indicConjunctBreakNone
188
+ }
189
+
190
+ func isExtendedPictographic(r rune) bool {
191
+ lo, hi := 0, len(extendedPictographicRanges)
192
+ for lo < hi {
193
+ middle := int(uint(lo+hi) >> 1)
194
+ candidate := extendedPictographicRanges[middle]
195
+ switch {
196
+ case r < candidate.lo:
197
+ hi = middle
198
+ case r > candidate.hi:
199
+ lo = middle + 1
200
+ default:
201
+ return true
202
+ }
203
+ }
204
+ return false
205
+ }
206
+
207
+ func isGraphemeControlClass(class graphemeBreakClass) bool {
208
+ return class == graphemeBreakControl || class == graphemeBreakCR || class == graphemeBreakLF
209
+ }
210
+
211
+ func isConjoiningGraphemeJoin(previous, next graphemeBreakClass) bool {
212
+ switch previous {
213
+ case graphemeBreakL:
214
+ return next == graphemeBreakL || next == graphemeBreakV || next == graphemeBreakLV || next == graphemeBreakLVT
215
+ case graphemeBreakLV, graphemeBreakV:
216
+ return next == graphemeBreakV || next == graphemeBreakT
217
+ case graphemeBreakLVT, graphemeBreakT:
218
+ return next == graphemeBreakT
219
+ default:
220
+ return false
221
+ }
222
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ttsc/lint",
3
- "version": "0.18.4",
4
- "description": "Reference ttsc plugin: ESLint-style lint rules hosted in the same Program/Checker as the type-check pass.",
3
+ "version": "0.19.1",
4
+ "description": "Reference ttsc plugin: ESLint-style lint rules over the TypeScript-Go Program used by the type-check pass.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
7
7
  "exports": {
@@ -26,6 +26,7 @@
26
26
  "files": [
27
27
  "README.md",
28
28
  "go.mod",
29
+ "internal",
29
30
  "lib",
30
31
  "linthost",
31
32
  "plugin",
@@ -36,7 +37,7 @@
36
37
  "@types/node": "^25.3.0",
37
38
  "rimraf": "^6.1.2",
38
39
  "typescript": "^7.0.2",
39
- "ttsc": "0.18.4"
40
+ "ttsc": "0.19.1"
40
41
  },
41
42
  "repository": {
42
43
  "type": "git",
@@ -0,0 +1,206 @@
1
+ package rule
2
+
3
+ import (
4
+ "encoding/json"
5
+
6
+ shimast "github.com/microsoft/typescript-go/shim/ast"
7
+ shimchecker "github.com/microsoft/typescript-go/shim/checker"
8
+ )
9
+
10
+ // ProjectIdentity names one loaded TypeScript Program without conflating the
11
+ // caller's path spelling with the filesystem identity used by the compiler.
12
+ // Empty explicit fields mean the caller did not provide that channel.
13
+ type ProjectIdentity struct {
14
+ LifecycleID string `json:"lifecycleId"`
15
+ InvocationCwd string `json:"invocationCwd"`
16
+ LogicalConfigPath string `json:"logicalConfigPath"`
17
+ LogicalProjectRoot string `json:"logicalProjectRoot"`
18
+ PhysicalConfigPath string `json:"physicalConfigPath"`
19
+ PhysicalProjectRoot string `json:"physicalProjectRoot"`
20
+ ExplicitProjectRoot string `json:"explicitProjectRoot,omitempty"`
21
+ PluginConfigOrigin string `json:"pluginConfigOrigin,omitempty"`
22
+ }
23
+
24
+ // ProjectRuleStatus describes whether a named project rule exists, was
25
+ // configured, and completed during the current Program cycle.
26
+ type ProjectRuleStatus string
27
+
28
+ const (
29
+ ProjectRuleAbsent ProjectRuleStatus = "absent"
30
+ ProjectRuleOff ProjectRuleStatus = "off"
31
+ ProjectRuleNotEvaluated ProjectRuleStatus = "not_evaluated"
32
+ ProjectRulePassed ProjectRuleStatus = "passed"
33
+ ProjectRuleFailed ProjectRuleStatus = "failed"
34
+ )
35
+
36
+ // ProjectFinding is a non-file finding retained in a project rule's cycle
37
+ // result. Project findings never contain edits or source ranges.
38
+ type ProjectFinding struct {
39
+ Message string
40
+ }
41
+
42
+ // ProjectRuleResult is one snapshot of a named project rule in the current
43
+ // Program cycle. State is the contributor-owned value attached during the
44
+ // project check; the host neither interprets nor synchronizes its contents.
45
+ // Findings is returned as a defensive copy by host result readers.
46
+ //
47
+ // Evaluated results retain a cycle-scoped failure channel through file-rule
48
+ // dispatch. Call Report or Fail immediately before a guarded operation, then
49
+ // call Context.ProjectResult again when the updated status is needed. Absent,
50
+ // off, and not-evaluated results have no state or live failure channel.
51
+ type ProjectRuleResult struct {
52
+ Status ProjectRuleStatus
53
+ State any
54
+ Findings []ProjectFinding
55
+
56
+ reporter ProjectReporter
57
+ }
58
+
59
+ // NewProjectRuleResult constructs one host-owned project-result snapshot.
60
+ // Contributor code normally receives this value from Context.ProjectResult
61
+ // and does not construct it.
62
+ func NewProjectRuleResult(
63
+ status ProjectRuleStatus,
64
+ state any,
65
+ findings []ProjectFinding,
66
+ reporter ProjectReporter,
67
+ ) ProjectRuleResult {
68
+ return ProjectRuleResult{
69
+ Status: status,
70
+ State: state,
71
+ Findings: append([]ProjectFinding(nil), findings...),
72
+ reporter: reporter,
73
+ }
74
+ }
75
+
76
+ // Fail marks this evaluated project result failed without adding a finding.
77
+ // It is a no-op after file dispatch or for a result that was not evaluated.
78
+ func (r ProjectRuleResult) Fail() {
79
+ if r.reporter != nil {
80
+ r.reporter.Fail()
81
+ }
82
+ }
83
+
84
+ // Report records one project finding and marks this evaluated result failed.
85
+ // Equal messages are deduplicated by the host. It is a no-op after file
86
+ // dispatch or for a result that was not evaluated.
87
+ func (r ProjectRuleResult) Report(message string) {
88
+ if r.reporter != nil {
89
+ r.reporter.Report(message)
90
+ }
91
+ }
92
+
93
+ // ProjectResultReader supplies live project state to later file-rule contexts.
94
+ // Hosts return ProjectRuleAbsent for names with no registered project rule.
95
+ type ProjectResultReader interface {
96
+ ProjectResult(name string) ProjectRuleResult
97
+ }
98
+
99
+ // ProjectRule is a contributor check that runs once for a loaded Program
100
+ // before any node rule dispatch. It has no AST visit list or synthetic file.
101
+ type ProjectRule interface {
102
+ Name() string
103
+ Check(ctx *ProjectContext)
104
+ }
105
+
106
+ // ProjectReporter is the cycle-scoped failure channel available to project
107
+ // helpers. Report records a deterministic project finding and also marks the
108
+ // current rule failed; Fail marks failure without adding a finding.
109
+ type ProjectReporter interface {
110
+ Fail()
111
+ Report(message string)
112
+ }
113
+
114
+ // ProjectContext contains the immutable inputs for one project-rule check.
115
+ // Sources is a defensive copy of the Program's tsconfig-selected user sources.
116
+ type ProjectContext struct {
117
+ Identity ProjectIdentity
118
+ Sources []*shimast.SourceFile
119
+ Checker *shimchecker.Checker
120
+ Severity Severity
121
+ Options json.RawMessage
122
+
123
+ reporter ProjectReporter
124
+ stateSetter projectStateSetter
125
+ }
126
+
127
+ type projectStateSetter interface {
128
+ SetState(state any)
129
+ }
130
+
131
+ // NewProjectContext constructs the context a host passes to ProjectRule.Check.
132
+ // Contributor code normally receives this value and does not construct it.
133
+ func NewProjectContext(
134
+ identity ProjectIdentity,
135
+ sources []*shimast.SourceFile,
136
+ checker *shimchecker.Checker,
137
+ severity Severity,
138
+ options json.RawMessage,
139
+ reporter ProjectReporter,
140
+ ) *ProjectContext {
141
+ copiedSources := append([]*shimast.SourceFile(nil), sources...)
142
+ stateSetter, _ := reporter.(projectStateSetter)
143
+ return &ProjectContext{
144
+ Identity: identity,
145
+ Sources: copiedSources,
146
+ Checker: checker,
147
+ Severity: severity,
148
+ Options: append(json.RawMessage(nil), options...),
149
+ reporter: reporter,
150
+ stateSetter: stateSetter,
151
+ }
152
+ }
153
+
154
+ // DecodeOptions unmarshals the configured project-rule options into out. A
155
+ // missing options tuple leaves out unchanged and returns nil.
156
+ func (c *ProjectContext) DecodeOptions(out interface{}) error {
157
+ if c == nil || len(c.Options) == 0 {
158
+ return nil
159
+ }
160
+ return json.Unmarshal(c.Options, out)
161
+ }
162
+
163
+ // SetState attaches one contributor-owned value to this rule's evaluated
164
+ // result. The exact value is returned to file rules in the same Program cycle;
165
+ // contributors own any synchronization needed inside it. The host does not
166
+ // serialize the value or retain it for a later watch or LSP rebuild.
167
+ func (c *ProjectContext) SetState(state any) {
168
+ if c == nil || c.stateSetter == nil || c.Severity == SeverityOff {
169
+ return
170
+ }
171
+ c.stateSetter.SetState(state)
172
+ }
173
+
174
+ // Fail marks the current project rule failed without adding a diagnostic.
175
+ func (c *ProjectContext) Fail() {
176
+ if c == nil || c.reporter == nil || c.Severity == SeverityOff {
177
+ return
178
+ }
179
+ c.reporter.Fail()
180
+ }
181
+
182
+ // Report records one non-file project finding and marks the rule failed.
183
+ func (c *ProjectContext) Report(message string) {
184
+ if c == nil || c.reporter == nil || c.Severity == SeverityOff {
185
+ return
186
+ }
187
+ c.reporter.Report(message)
188
+ }
189
+
190
+ var projectRegistry []ProjectRule
191
+
192
+ // RegisterProject adds a contributor project rule to the global registry.
193
+ // Hosts validate duplicate names after all contributor init functions finish.
194
+ func RegisterProject(r ProjectRule) {
195
+ if r == nil {
196
+ panic("rule: RegisterProject called with nil rule")
197
+ }
198
+ projectRegistry = append(projectRegistry, r)
199
+ }
200
+
201
+ // RegisteredProjects returns a defensive copy of all registered project rules.
202
+ func RegisteredProjects() []ProjectRule {
203
+ out := make([]ProjectRule, len(projectRegistry))
204
+ copy(out, projectRegistry)
205
+ return out
206
+ }
package/rule/rule.go CHANGED
@@ -112,6 +112,39 @@ type DeclarationFileRule interface {
112
112
  VisitsDeclarationFiles() bool
113
113
  }
114
114
 
115
+ // TypeAwareRule is an optional marker contributors implement to declare
116
+ // whether their rule reads `Context.Checker`. The host cannot infer a
117
+ // third-party rule's shape, so a contributor that does not implement this
118
+ // marker keeps the conservative default: it is treated as type-aware and
119
+ // receives a live checker.
120
+ //
121
+ // Being treated as type-aware is not free. The host creates a standalone
122
+ // checker spanning every source file, and the engine walks files serially so
123
+ // that checker is never accessed concurrently. A purely syntactic rule that
124
+ // never touches `Context.Checker` pays both costs for nothing.
125
+ //
126
+ // A contributor whose rule is AST-only can implement this with
127
+ // `NeedsTypeChecker() bool { return false }` to opt out of the checker path,
128
+ // preserving the engine's parallel file walk. Returning `true` is equivalent
129
+ // to not implementing the interface at all. A rule that returns `false` must
130
+ // not read `Context.Checker`: the host is free to leave it nil.
131
+ type TypeAwareRule interface {
132
+ Rule
133
+ NeedsTypeChecker() bool
134
+ }
135
+
136
+ // OptionsRule is an optional marker contributors implement to declare whether
137
+ // their rule accepts an options slot in its `[severity, options]` setting.
138
+ // Contributor rules default to accepting options for backward compatibility
139
+ // with the original public Context.Options contract. Return false for a
140
+ // genuinely optionless rule so the host can reject accidental payloads before
141
+ // linting. The domain-specific method name prevents an unrelated generic
142
+ // AcceptsOptions method on an existing contributor from opting in by accident.
143
+ // ProjectRule implementations may use the same marker.
144
+ type OptionsRule interface {
145
+ AcceptsTtscLintOptions() bool
146
+ }
147
+
115
148
  // Reporter is the engine-supplied callback that records a finding. The
116
149
  // host implements this and passes it to `NewContext` when invoking a
117
150
  // contributor rule.
@@ -191,6 +224,7 @@ type Context struct {
191
224
  Options json.RawMessage
192
225
 
193
226
  reporter Reporter
227
+ results ProjectResultReader
194
228
  }
195
229
 
196
230
  // NewContext constructs a Context for the engine to pass into a
@@ -202,14 +236,37 @@ func NewContext(
202
236
  severity Severity,
203
237
  options json.RawMessage,
204
238
  reporter Reporter,
239
+ ) *Context {
240
+ return NewContextWithProjectResults(file, checker, severity, options, reporter, nil)
241
+ }
242
+
243
+ // NewContextWithProjectResults constructs a file-rule Context with the live
244
+ // project results for the same loaded Program cycle.
245
+ func NewContextWithProjectResults(
246
+ file *shimast.SourceFile,
247
+ checker *shimchecker.Checker,
248
+ severity Severity,
249
+ options json.RawMessage,
250
+ reporter Reporter,
251
+ results ProjectResultReader,
205
252
  ) *Context {
206
253
  return &Context{
207
254
  File: file,
208
255
  Checker: checker,
209
256
  Severity: severity,
210
- Options: options,
257
+ Options: append(json.RawMessage(nil), options...),
211
258
  reporter: reporter,
259
+ results: results,
260
+ }
261
+ }
262
+
263
+ // ProjectResult returns a current snapshot for a named project rule in this
264
+ // file's Program cycle. Missing registrations return ProjectRuleAbsent.
265
+ func (c *Context) ProjectResult(name string) ProjectRuleResult {
266
+ if c == nil || c.results == nil {
267
+ return ProjectRuleResult{Status: ProjectRuleAbsent}
212
268
  }
269
+ return c.results.ProjectResult(name)
213
270
  }
214
271
 
215
272
  // DecodeOptions unmarshals the rule's options blob into `out`. Returns
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ type TtscPluginDescriptor = {
22
22
  capabilities?: {
23
23
  diagnosticsTiming?: boolean;
24
24
  lsp?: boolean;
25
+ projectContextArgs?: boolean;
25
26
  threadingArgs?: boolean;
26
27
  };
27
28
  contributors?: TtscPluginContributor[];
@@ -43,6 +44,12 @@ type TtscPluginFactoryContext<TConfig> = {
43
44
  /** This descriptor's own path — the ESM-safe replacement for `__filename`. */
44
45
  filename: string;
45
46
  plugin: TConfig;
47
+ /**
48
+ * Caller-declared anchor for plugin config-file discovery, present when the
49
+ * embedder compiles through a generated tsconfig outside the project (see
50
+ * `ITtscPluginFactoryContext.pluginConfigDir`).
51
+ */
52
+ pluginConfigDir?: string;
46
53
  projectRoot: string;
47
54
  tsconfig: string;
48
55
  };
@@ -104,7 +111,9 @@ const FRAMEWORK_KEYS = new Set<string>([
104
111
  * (`lint.config.{ts,cts,mts,js,cjs,mjs,json}` or `ttsc-lint.config.*`). The
105
112
  * tsconfig plugin entry carries no rule or plugin surface — it optionally names
106
113
  * the config file via `configFile`, otherwise the file is discovered by walking
107
- * upward from the tsconfig directory.
114
+ * upward from the tsconfig directory (or from the caller-declared
115
+ * `pluginConfigDir` when the resolved tsconfig is a generated wrapper in a temp
116
+ * directory).
108
117
  *
109
118
  * The factory locates the config file, evaluates it (via ttsx for TS / ESM
110
119
  * sources, `require` for CommonJS, `JSON.parse` for JSON), reads every
@@ -121,7 +130,12 @@ export default function createTtscPlugin(
121
130
  // declared, so consumers (and the existing key-shape regression
122
131
  // tests) see the same surface as before this feature shipped.
123
132
  const descriptor: TtscPluginDescriptor = {
124
- capabilities: { diagnosticsTiming: true, lsp: true, threadingArgs: true },
133
+ capabilities: {
134
+ diagnosticsTiming: true,
135
+ lsp: true,
136
+ projectContextArgs: true,
137
+ threadingArgs: true,
138
+ },
125
139
  name: "@ttsc/lint",
126
140
  reportsTypeScriptDiagnostics: true,
127
141
  // `context.dirname` is this descriptor's own directory in every load mode —
@@ -187,9 +201,12 @@ type ConfigPluginEntry = { namespace: string; source: string };
187
201
  * Resolves the contributor lint plugins declared in the project's lint config
188
202
  * file.
189
203
  *
190
- * - When the tsconfig plugin entry sets `configFile`, that exact file is loaded.
204
+ * - When the tsconfig plugin entry sets `configFile`, that exact file is loaded
205
+ * (relative paths resolve against the tsconfig directory, or against the
206
+ * caller-declared `pluginConfigDir` when present).
191
207
  * - Otherwise a `lint.config.*` / `ttsc-lint.config.*` file is discovered by
192
- * walking upward from the tsconfig directory.
208
+ * walking upward from the tsconfig directory (or from `pluginConfigDir` when
209
+ * present).
193
210
  *
194
211
  * Returns an empty array when no config file is set or discovered — the Go
195
212
  * sidecar surfaces the missing-config error; the factory only needs to forward
@@ -201,7 +218,7 @@ function resolveConfigFileContributors(
201
218
  const configFile = readConfigFileOption(context);
202
219
  const configPath =
203
220
  configFile !== undefined
204
- ? path.resolve(tsconfigBaseDir(context), configFile)
221
+ ? path.resolve(pluginConfigBaseDir(context), configFile)
205
222
  : findLintConfigFile(context);
206
223
  if (!configPath || !fs.existsSync(configPath)) return [];
207
224
 
@@ -260,18 +277,15 @@ function readConfigFileOption(
260
277
  function findLintConfigFile(
261
278
  context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
262
279
  ): string | undefined {
263
- // Mirror the Go-side discovery origins: walk upward from the tsconfig
264
- // directory first, then fall back to the working directory. The fallback
265
- // covers callers that point at an out-of-tree tsconfig (e.g. a
266
- // TtscCompiler invocation whose wrapper tsconfig lives in the system temp
267
- // dir while cwd/projectRoot is the real project) without it the walk
268
- // dead-ends in the temp dir's ancestry and the project's config (and its
269
- // contributor plugins) are silently missed.
270
- const tsconfigDir = tsconfigBaseDir(context);
271
- const cwd = path.resolve(context.cwd ?? context.projectRoot);
272
- for (const origin of tsconfigDir === cwd
273
- ? [tsconfigDir]
274
- : [tsconfigDir, cwd]) {
280
+ // Mirror the Go side (driver.PluginConfigBaseDir): the caller-declared
281
+ // pluginConfigDir is the single walk origin when present it names the
282
+ // real project when the resolved tsconfig is a generated wrapper in a temp
283
+ // dir (@ttsc/unplugin's alias overlay), and it keeps the wrapper's temp
284
+ // ancestry out of the walk so a stray config planted there is never
285
+ // honored. Otherwise walk upward from the tsconfig directory first, then
286
+ // fall back to the working directory: that covers callers that point at an
287
+ // out-of-tree tsconfig without declaring an anchor.
288
+ for (const origin of discoveryConfigBaseDirs(context)) {
275
289
  const discovered = findLintConfigFileFrom(origin);
276
290
  if (discovered !== undefined) {
277
291
  return discovered;
@@ -280,6 +294,17 @@ function findLintConfigFile(
280
294
  return undefined;
281
295
  }
282
296
 
297
+ function discoveryConfigBaseDirs(
298
+ context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
299
+ ): string[] {
300
+ if (context.pluginConfigDir) {
301
+ return [path.resolve(context.cwd ?? ".", context.pluginConfigDir)];
302
+ }
303
+ const tsconfigDir = tsconfigBaseDir(context);
304
+ const cwd = path.resolve(context.cwd ?? context.projectRoot);
305
+ return tsconfigDir === cwd ? [tsconfigDir] : [tsconfigDir, cwd];
306
+ }
307
+
283
308
  function findLintConfigFileFrom(origin: string): string | undefined {
284
309
  // Mirror the Go-side discovery loop: walk from `origin` upward, returning
285
310
  // the first directory that has exactly one of the candidate filenames.
@@ -317,6 +342,22 @@ function findLintConfigFileFrom(origin: string): string | undefined {
317
342
  }
318
343
  }
319
344
 
345
+ /**
346
+ * Base directory for resolving a relative `configFile` from the tsconfig plugin
347
+ * entry. Mirrors the Go side (driver.PluginConfigBaseDir): the caller-declared
348
+ * pluginConfigDir wins when present — the resolved tsconfig is then a generated
349
+ * wrapper in a temp directory that no longer identifies the project — otherwise
350
+ * the tsconfig directory, falling back to the working directory.
351
+ */
352
+ function pluginConfigBaseDir(
353
+ context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
354
+ ): string {
355
+ if (context.pluginConfigDir) {
356
+ return path.resolve(context.cwd ?? ".", context.pluginConfigDir);
357
+ }
358
+ return tsconfigBaseDir(context);
359
+ }
360
+
320
361
  function tsconfigBaseDir(
321
362
  context: TtscPluginFactoryContext<ITtscLintPluginConfig>,
322
363
  ): string {
@@ -38,6 +38,9 @@ export interface ITtscLintConfig {
38
38
  * Built-in rules are concrete interface properties for autocomplete and typo
39
39
  * checking. Namespaced families and contributor rules use the familiar slash
40
40
  * form such as `react/jsx-key` or `demo/no-demo`.
41
+ *
42
+ * Project-scoped contributor rules use this same map, but their configuration
43
+ * must come from entries without a `files` selector.
41
44
  */
42
45
  rules?: ITtscLintRules;
43
46