@nestia/core 12.0.0-dev.20260520.1 → 12.0.0-dev.20260521.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 (30) hide show
  1. package/lib/transform.d.ts +8 -0
  2. package/lib/transform.js +28 -12
  3. package/lib/transform.js.map +1 -1
  4. package/native/cmd/ttsc-nestia/main.go +2 -63
  5. package/native/go.mod +1 -1
  6. package/native/transform/ast.go +32 -0
  7. package/native/{cmd/ttsc-nestia → transform}/build.go +14 -35
  8. package/native/{cmd/ttsc-nestia → transform}/cleanup.go +1 -1
  9. package/native/transform/cleanup_test.go +76 -0
  10. package/native/transform/commonjs_import_alias_test.go +49 -0
  11. package/native/transform/core_dispatch_test.go +127 -0
  12. package/native/{cmd/ttsc-nestia → transform}/core_querify.go +1 -1
  13. package/native/{cmd/ttsc-nestia → transform}/core_transform.go +26 -26
  14. package/native/{cmd/ttsc-nestia → transform}/core_websocket.go +10 -10
  15. package/native/transform/exports.go +13 -0
  16. package/native/{cmd/ttsc-nestia → transform}/path_rewrite.go +1 -1
  17. package/native/transform/path_rewrite_test.go +243 -0
  18. package/native/{cmd/ttsc-nestia → transform}/printer.go +1 -1
  19. package/native/{cmd/ttsc-nestia → transform}/rewrite.go +3 -3
  20. package/native/transform/rewrite_test.go +118 -0
  21. package/native/transform/rewrite_unique_base_test.go +48 -0
  22. package/native/transform/run.go +72 -0
  23. package/native/{cmd/ttsc-nestia → transform}/transform.go +25 -36
  24. package/native/{cmd/ttsc-nestia → transform}/typia_fast.go +1 -1
  25. package/native/{cmd/ttsc-nestia → transform}/typia_replacement.go +1 -1
  26. package/native/transform.cjs +34 -12
  27. package/package.json +8 -7
  28. package/src/transform.ts +39 -20
  29. package/native/cmd/ttsc-nestia/sdk_metadata_json.go +0 -327
  30. package/native/cmd/ttsc-nestia/sdk_transform.go +0 -1541
@@ -1,4 +1,4 @@
1
- package main
1
+ package transform
2
2
 
3
3
  import (
4
4
  "fmt"
@@ -79,7 +79,7 @@ func newNestiaCoreTransformState(prog *driver.Program, options nestiaCoreOptions
79
79
 
80
80
  var nestiaCoreFactory = shimast.NewNodeFactory(shimast.NodeFactoryHooks{})
81
81
 
82
- const nestiaCoreKindDecorator = shimast.KindDecorator
82
+ const NestiaCoreKindDecorator = shimast.KindDecorator
83
83
 
84
84
  type nestiaCoreFileContext struct {
85
85
  file *shimast.SourceFile
@@ -121,18 +121,18 @@ func collectNestiaCoreSourceRewriteMap(
121
121
  prog *driver.Program,
122
122
  plan plugin.Plan,
123
123
  onlyFile string,
124
- ) (map[string][]transformSourceRewrite, []typiaTransformDiagnostic) {
124
+ ) (map[string][]SourceRewrite, []Diagnostic) {
125
125
  if plan.Core == false {
126
- return map[string][]transformSourceRewrite{}, nil
126
+ return map[string][]SourceRewrite{}, nil
127
127
  }
128
128
  options := readNestiaCoreOptions(plan)
129
129
  sites, diagnostics := collectNestiaCoreSites(newNestiaCoreTransformState(prog, options))
130
- rewrites := map[string][]transformSourceRewrite{}
130
+ rewrites := map[string][]SourceRewrite{}
131
131
  for _, site := range sites {
132
132
  if onlyFile != "" && filepath.ToSlash(site.FilePath) != filepath.ToSlash(onlyFile) {
133
133
  continue
134
134
  }
135
- source, ok := sourceFileText(site.File)
135
+ source, ok := SourceFileText(site.File)
136
136
  if !ok {
137
137
  diagnostics = append(diagnostics, nestiaCoreDiagnostic(site, "source text is unavailable"))
138
138
  continue
@@ -143,7 +143,7 @@ func collectNestiaCoreSourceRewriteMap(
143
143
  continue
144
144
  }
145
145
  replacement := appendArgumentsText(source[open+1:close], site.Arguments)
146
- rewrites[filepath.ToSlash(site.FilePath)] = append(rewrites[filepath.ToSlash(site.FilePath)], transformSourceRewrite{
146
+ rewrites[filepath.ToSlash(site.FilePath)] = append(rewrites[filepath.ToSlash(site.FilePath)], SourceRewrite{
147
147
  start: open + 1,
148
148
  end: close,
149
149
  replacement: replacement,
@@ -156,7 +156,7 @@ func collectNestiaCoreBuildRewrites(
156
156
  prog *driver.Program,
157
157
  plan plugin.Plan,
158
158
  rewrites *nativeRewriteSet,
159
- ) []typiaTransformDiagnostic {
159
+ ) []Diagnostic {
160
160
  if plan.Core == false {
161
161
  return nil
162
162
  }
@@ -180,7 +180,7 @@ func collectNestiaCoreBuildRewrites(
180
180
  }
181
181
 
182
182
  func nestiaCoreOriginalArgumentText(site nestiaCoreSite) string {
183
- source, ok := sourceFileText(site.File)
183
+ source, ok := SourceFileText(site.File)
184
184
  if !ok {
185
185
  return ""
186
186
  }
@@ -199,9 +199,9 @@ func nestiaCoreStableOriginalArgumentText(site nestiaCoreSite) string {
199
199
  return text
200
200
  }
201
201
 
202
- func collectNestiaCoreSites(state *nestiaCoreTransformState) ([]nestiaCoreSite, []typiaTransformDiagnostic) {
202
+ func collectNestiaCoreSites(state *nestiaCoreTransformState) ([]nestiaCoreSite, []Diagnostic) {
203
203
  sites := []nestiaCoreSite{}
204
- diagnostics := []typiaTransformDiagnostic{}
204
+ diagnostics := []Diagnostic{}
205
205
  prog := state.prog
206
206
  if nestiaCoreStrictMode(prog) == false {
207
207
  diagnostics = append(diagnostics, nestiaCoreGlobalDiagnostic("@nestia/core", "strict mode is required."))
@@ -227,7 +227,7 @@ func visitNestiaCoreNode(
227
227
  context nestiaCoreFileContext,
228
228
  node *shimast.Node,
229
229
  sites *[]nestiaCoreSite,
230
- diagnostics *[]typiaTransformDiagnostic,
230
+ diagnostics *[]Diagnostic,
231
231
  ) {
232
232
  if node == nil {
233
233
  return
@@ -317,7 +317,7 @@ func visitNestiaCoreNode(
317
317
  if len(candidates) == 0 {
318
318
  break
319
319
  }
320
- typ := nestiaCoreMethodReturnType(state.prog, node)
320
+ typ := NestiaCoreMethodReturnType(state.prog, node)
321
321
  if typ != nil {
322
322
  for _, candidate := range candidates {
323
323
  site, ok, err := transformNestiaCoreMethodDecorator(
@@ -489,7 +489,7 @@ func (state *nestiaCoreTransformState) cacheKey(
489
489
  }
490
490
 
491
491
  func nestiaCoreRawDecoratorCall(decorator *shimast.Node) (*shimast.CallExpression, []string, bool) {
492
- if decorator == nil || decorator.Kind != nestiaCoreKindDecorator {
492
+ if decorator == nil || decorator.Kind != NestiaCoreKindDecorator {
493
493
  return nil, nil, false
494
494
  }
495
495
  expression := decorator.AsDecorator().Expression
@@ -497,7 +497,7 @@ func nestiaCoreRawDecoratorCall(decorator *shimast.Node) (*shimast.CallExpressio
497
497
  return nil, nil, false
498
498
  }
499
499
  call := expression.AsCallExpression()
500
- segments := nestiaCoreExpressionSegments(call.Expression)
500
+ segments := NestiaCoreExpressionSegments(call.Expression)
501
501
  if len(segments) == 0 {
502
502
  return nil, nil, false
503
503
  }
@@ -600,7 +600,7 @@ func nestiaCoreDecoratorReference(
600
600
  if nestiaCorePotentialDecoratorSegments(canonical) == false {
601
601
  return false
602
602
  }
603
- return isNestiaCoreCall(prog, decorator.AsDecorator().Expression)
603
+ return IsNestiaCoreCall(prog, decorator.AsDecorator().Expression)
604
604
  }
605
605
 
606
606
  func nestiaCorePotentialDecoratorSegments(segments []string) bool {
@@ -609,7 +609,7 @@ func nestiaCorePotentialDecoratorSegments(segments []string) bool {
609
609
  (len(segments) != 0 && segments[len(segments)-1] == "WebSocketRoute")
610
610
  }
611
611
 
612
- func isNestiaCoreCall(prog *driver.Program, node *shimast.Node) bool {
612
+ func IsNestiaCoreCall(prog *driver.Program, node *shimast.Node) bool {
613
613
  signature := prog.Checker.GetResolvedSignature(node)
614
614
  if signature == nil || signature.Declaration() == nil {
615
615
  return false
@@ -1406,7 +1406,7 @@ func cleanupNestiaCorePrintedExpression(text string) string {
1406
1406
 
1407
1407
  var nestiaCoreSingleParameterArrowPattern = regexp.MustCompile(`(^|[\s(=,:?])([A-Za-z_$][A-Za-z0-9_$]*) =>`)
1408
1408
 
1409
- func nestiaCoreMethodReturnType(prog *driver.Program, node *shimast.Node) *shimchecker.Type {
1409
+ func NestiaCoreMethodReturnType(prog *driver.Program, node *shimast.Node) *shimchecker.Type {
1410
1410
  signature := prog.Checker.GetSignatureFromDeclaration(node)
1411
1411
  if signature == nil {
1412
1412
  return nil
@@ -1449,7 +1449,7 @@ func nestiaCoreShouldSkipMethodDecorator(prog *driver.Program, call *shimast.Cal
1449
1449
  }
1450
1450
 
1451
1451
  func nestiaCoreHasPathLiteralArgument(call *shimast.CallExpression) bool {
1452
- source, ok := sourceFileText(shimast.GetSourceFileOfNode(call.AsNode()))
1452
+ source, ok := SourceFileText(shimast.GetSourceFileOfNode(call.AsNode()))
1453
1453
  if !ok {
1454
1454
  return false
1455
1455
  }
@@ -1498,7 +1498,7 @@ func appendArgumentsText(current string, arguments []string) string {
1498
1498
  return current + ", " + next
1499
1499
  }
1500
1500
 
1501
- func nestiaCoreExpressionSegments(node *shimast.Node) []string {
1501
+ func NestiaCoreExpressionSegments(node *shimast.Node) []string {
1502
1502
  if node == nil {
1503
1503
  return nil
1504
1504
  }
@@ -1512,7 +1512,7 @@ func nestiaCoreExpressionSegments(node *shimast.Node) []string {
1512
1512
  if access == nil {
1513
1513
  return nil
1514
1514
  }
1515
- left := nestiaCoreExpressionSegments(access.Expression)
1515
+ left := NestiaCoreExpressionSegments(access.Expression)
1516
1516
  name := access.Name()
1517
1517
  if len(left) == 0 || name == nil || name.Kind != shimast.KindIdentifier {
1518
1518
  return nil
@@ -1523,7 +1523,7 @@ func nestiaCoreExpressionSegments(node *shimast.Node) []string {
1523
1523
  }
1524
1524
 
1525
1525
  func nestiaCoreModuloNode(node *shimast.Node) *shimast.Node {
1526
- segments := nestiaCoreExpressionSegments(node)
1526
+ segments := NestiaCoreExpressionSegments(node)
1527
1527
  if len(segments) == 0 {
1528
1528
  return nestiaCoreFactory.NewIdentifier("nestia_core_transform")
1529
1529
  }
@@ -1688,7 +1688,7 @@ func commonJSImportAliasBase(module string) string {
1688
1688
  return text
1689
1689
  }
1690
1690
 
1691
- func nestiaCoreDiagnostic(site nestiaCoreSite, message string) typiaTransformDiagnostic {
1691
+ func nestiaCoreDiagnostic(site nestiaCoreSite, message string) Diagnostic {
1692
1692
  line, column := 0, 0
1693
1693
  if site.File != nil && site.Call != nil {
1694
1694
  if pos := site.Call.AsNode().Pos(); pos >= 0 {
@@ -1696,7 +1696,7 @@ func nestiaCoreDiagnostic(site nestiaCoreSite, message string) typiaTransformDia
1696
1696
  line, column = l+1, c+1
1697
1697
  }
1698
1698
  }
1699
- return typiaTransformDiagnostic{
1699
+ return Diagnostic{
1700
1700
  File: site.FilePath,
1701
1701
  Line: line,
1702
1702
  Column: column,
@@ -1705,8 +1705,8 @@ func nestiaCoreDiagnostic(site nestiaCoreSite, message string) typiaTransformDia
1705
1705
  }
1706
1706
  }
1707
1707
 
1708
- func nestiaCoreGlobalDiagnostic(code string, message string) typiaTransformDiagnostic {
1709
- return typiaTransformDiagnostic{
1708
+ func nestiaCoreGlobalDiagnostic(code string, message string) Diagnostic {
1709
+ return Diagnostic{
1710
1710
  Code: code,
1711
1711
  Message: message,
1712
1712
  }
@@ -1,4 +1,4 @@
1
- package main
1
+ package transform
2
2
 
3
3
  import (
4
4
  "fmt"
@@ -15,23 +15,23 @@ func validateNestiaCoreWebSocketRoute(
15
15
  method *shimast.Node,
16
16
  call *shimast.CallExpression,
17
17
  segments []string,
18
- ) []typiaTransformDiagnostic {
18
+ ) []Diagnostic {
19
19
  if call == nil || len(segments) == 0 || segments[len(segments)-1] != "WebSocketRoute" {
20
20
  _ = call
21
21
  return nil
22
22
  }
23
- diagnostics := []typiaTransformDiagnostic{}
23
+ diagnostics := []Diagnostic{}
24
24
  accepted := false
25
25
  methodDecl := method.AsMethodDeclaration()
26
26
  if methodDecl == nil || methodDecl.Parameters == nil {
27
- return []typiaTransformDiagnostic{nestiaCoreWebSocketDiagnostic(context.file, method, "WebSocketRoute", fmt.Sprintf(
27
+ return []Diagnostic{nestiaCoreWebSocketDiagnostic(context.file, method, "WebSocketRoute", fmt.Sprintf(
28
28
  "method %q must have at least one parameter decorated by @WebSocketRoute.Acceptor().",
29
- nestiaSDKMethodName(method),
29
+ NodeName(method),
30
30
  ))}
31
31
  }
32
32
  for _, param := range methodDecl.Parameters.Nodes {
33
33
  category := nestiaCoreWebSocketParameterCategory(prog, param)
34
- name := nestiaSDKParameterName(param)
34
+ name := NodeName(param)
35
35
  if category == "" {
36
36
  diagnostics = append(diagnostics, nestiaCoreWebSocketDiagnostic(context.file, param, "WebSocketRoute", fmt.Sprintf(
37
37
  "parameter %q is not decorated with nested function of WebSocketRoute module.",
@@ -60,7 +60,7 @@ func validateNestiaCoreWebSocketRoute(
60
60
  if accepted == false {
61
61
  diagnostics = append(diagnostics, nestiaCoreWebSocketDiagnostic(context.file, method, "WebSocketRoute", fmt.Sprintf(
62
62
  "method %q must have at least one parameter decorated by @WebSocketRoute.Acceptor().",
63
- nestiaSDKMethodName(method),
63
+ NodeName(method),
64
64
  )))
65
65
  }
66
66
  return diagnostics
@@ -82,7 +82,7 @@ func nestiaCoreWebSocketParameterTypeName(param *shimast.Node) string {
82
82
  if param == nil || param.AsParameterDeclaration() == nil || param.AsParameterDeclaration().Type == nil {
83
83
  return ""
84
84
  }
85
- text := nestiaSDKTypeNodeText(param.AsParameterDeclaration().Type)
85
+ text := NodeText(param.AsParameterDeclaration().Type)
86
86
  text = strings.TrimSpace(text)
87
87
  if index := strings.Index(text, "<"); index >= 0 {
88
88
  text = text[:index]
@@ -93,7 +93,7 @@ func nestiaCoreWebSocketParameterTypeName(param *shimast.Node) string {
93
93
  return text
94
94
  }
95
95
 
96
- func nestiaCoreWebSocketDiagnostic(file *shimast.SourceFile, node *shimast.Node, kind string, message string) typiaTransformDiagnostic {
96
+ func nestiaCoreWebSocketDiagnostic(file *shimast.SourceFile, node *shimast.Node, kind string, message string) Diagnostic {
97
97
  filePath := ""
98
98
  line, column := 0, 0
99
99
  if file != nil {
@@ -105,7 +105,7 @@ func nestiaCoreWebSocketDiagnostic(file *shimast.SourceFile, node *shimast.Node,
105
105
  }
106
106
  }
107
107
  }
108
- return typiaTransformDiagnostic{
108
+ return Diagnostic{
109
109
  File: filePath,
110
110
  Line: line,
111
111
  Column: column,
@@ -0,0 +1,13 @@
1
+ package transform
2
+
3
+ // NewSourceRewrite builds a SourceRewrite from another Go module (e.g. the
4
+ // `@nestia/sdk` plugin). SourceRewrite keeps its span fields unexported so
5
+ // that the only way to produce one is this constructor or the in-package
6
+ // collectors — every rewrite therefore carries a validated [start,end) span.
7
+ func NewSourceRewrite(start, end int, replacement string) SourceRewrite {
8
+ return SourceRewrite{
9
+ start: start,
10
+ end: end,
11
+ replacement: replacement,
12
+ }
13
+ }
@@ -1,4 +1,4 @@
1
- package main
1
+ package transform
2
2
 
3
3
  import (
4
4
  "path/filepath"
@@ -0,0 +1,243 @@
1
+ package transform
2
+
3
+ import "testing"
4
+
5
+ // Verifies emittedJavaScriptExtension maps source-file extensions to the
6
+ // matching JavaScript output extension that tsgo writes.
7
+ //
8
+ // The native rewrite scan asks for the post-emit name of every registered
9
+ // source. A mismatch here means the rewrite searches for `.js` files when
10
+ // the project actually emits `.mjs` (ESM packages) or `.cjs`, and the
11
+ // "could not locate <call>" failure path then fires on legitimate
12
+ // builds. The case-insensitive switch covers Windows-style mixed-case
13
+ // `.MTS` paths some toolchains produce.
14
+ //
15
+ // 1. Cover the three explicit branches (.mts, .cts, default).
16
+ // 2. Cover the case-insensitive variants (.MTS, .CTS).
17
+ // 3. Cover the default fallback for unknown / extensionless inputs.
18
+ func TestEmittedJavaScriptExtensionMapsSourceToOutput(t *testing.T) {
19
+ cases := []struct {
20
+ name string
21
+ source string
22
+ want string
23
+ }{
24
+ {"plain ts", "/repo/src/index.ts", ".js"},
25
+ {"plain tsx", "/repo/src/Component.tsx", ".js"},
26
+ {"esm mts", "/repo/src/index.mts", ".mjs"},
27
+ {"cjs cts", "/repo/src/index.cts", ".cjs"},
28
+ {"uppercase mts", "/repo/src/Index.MTS", ".mjs"},
29
+ {"uppercase cts", "/repo/src/Index.CTS", ".cjs"},
30
+ {"declaration only", "/repo/src/types.d.ts", ".js"},
31
+ {"unknown extension", "/repo/src/data.json", ".js"},
32
+ {"extensionless", "/repo/src/README", ".js"},
33
+ }
34
+ for _, tc := range cases {
35
+ t.Run(tc.name, func(t *testing.T) {
36
+ if got := emittedJavaScriptExtension(tc.source); got != tc.want {
37
+ t.Fatalf("emittedJavaScriptExtension(%q) = %q, want %q", tc.source, got, tc.want)
38
+ }
39
+ })
40
+ }
41
+ }
42
+
43
+ // Verifies matchPattern strictly matches the literal-pattern branch and
44
+ // returns the wildcard capture for star-bearing patterns.
45
+ //
46
+ // `matchPattern` underpins every `paths` resolver entry — a regression in
47
+ // the prefix / suffix slice arithmetic would silently break tsconfig
48
+ // `paths` aliasing for any project that uses it. The literal branch must
49
+ // reject `pattern !== specifier`; the wildcard branch must demand BOTH
50
+ // the prefix and suffix to anchor before extracting the capture.
51
+ //
52
+ // 1. Literal patterns match only on byte equality.
53
+ // 2. Single-wildcard patterns extract the gap between prefix and suffix.
54
+ // 3. Mismatched prefix or suffix returns no capture.
55
+ func TestMatchPatternLiteralAndWildcardBranches(t *testing.T) {
56
+ cases := []struct {
57
+ name string
58
+ pattern string
59
+ input string
60
+ want string
61
+ matched bool
62
+ }{
63
+ {"literal match", "@api", "@api", "", true},
64
+ {"literal mismatch", "@api", "@api/lib", "", false},
65
+ {"wildcard captures middle", "@api/*", "@api/users", "users", true},
66
+ {"wildcard captures nested", "@api/lib/*", "@api/lib/sub/file", "sub/file", true},
67
+ {"wildcard mismatch prefix", "@api/*", "@other/users", "", false},
68
+ {"wildcard mismatch suffix", "@api/*.ts", "@api/users.tsx", "", false},
69
+ {"wildcard between prefix and suffix", "src/*/index.ts", "src/foo/index.ts", "foo", true},
70
+ {"empty capture allowed", "prefix*suffix", "prefixsuffix", "", true},
71
+ {"second wildcard treated as literal", "@api/*/lib/*", "@api/foo/lib/*", "foo", true},
72
+ {"second wildcard does not expand", "@api/*/lib/*", "@api/foo/lib/bar", "", false},
73
+ }
74
+ for _, tc := range cases {
75
+ t.Run(tc.name, func(t *testing.T) {
76
+ got, ok := matchPattern(tc.pattern, tc.input)
77
+ if ok != tc.matched {
78
+ t.Fatalf("matchPattern(%q, %q) ok = %v, want %v", tc.pattern, tc.input, ok, tc.matched)
79
+ }
80
+ if ok && got != tc.want {
81
+ t.Fatalf("matchPattern(%q, %q) capture = %q, want %q", tc.pattern, tc.input, got, tc.want)
82
+ }
83
+ })
84
+ }
85
+ }
86
+
87
+ // Verifies patternRank assigns longer literal-character counts higher rank
88
+ // so the longest non-wildcard pattern wins when several match.
89
+ //
90
+ // tsconfig `paths` resolution sorts candidates by `patternRank` descending
91
+ // to break ties — without this ordering the resolver could pick the
92
+ // loosest pattern over a more specific one (e.g. `*` over `@api/*`).
93
+ //
94
+ // 1. A pure-wildcard pattern ranks 0.
95
+ // 2. Each non-wildcard character adds 1 regardless of position.
96
+ // 3. Multiple wildcards do not double-count literal segments.
97
+ func TestPatternRankCountsNonWildcardCharacters(t *testing.T) {
98
+ cases := []struct {
99
+ pattern string
100
+ want int
101
+ }{
102
+ {"*", 0},
103
+ {"@api/*", 5},
104
+ {"@api/lib/*", 9},
105
+ {"src/*/index.ts", 13},
106
+ {"prefix*suffix", 12},
107
+ }
108
+ for _, tc := range cases {
109
+ if got := patternRank(tc.pattern); got != tc.want {
110
+ t.Fatalf("patternRank(%q) = %d, want %d", tc.pattern, got, tc.want)
111
+ }
112
+ }
113
+ }
114
+
115
+ // Verifies normalizePath collapses redundant POSIX separators and
116
+ // resolves `..` segments via filepath.Clean.
117
+ //
118
+ // The rewrite pipeline keys outputs and sources by their normalized
119
+ // path. A regression here would split a single logical source into
120
+ // multiple cache entries, defeating `nativeRewriteSet`'s deduplication.
121
+ // `filepath.ToSlash` runs after Clean — on Linux it is a no-op for
122
+ // already-POSIX input; backslash handling is delegated to the platform.
123
+ //
124
+ // 1. Empty input returns empty.
125
+ // 2. Adjacent separators collapse.
126
+ // 3. `..` segments are resolved relative to the preceding directory.
127
+ // 4. A leading `./` is stripped (filepath.Clean canonicalization).
128
+ func TestNormalizePathProducesPosixSlashes(t *testing.T) {
129
+ cases := []struct {
130
+ input string
131
+ want string
132
+ }{
133
+ {"", ""},
134
+ {"/repo/src/index.ts", "/repo/src/index.ts"},
135
+ {"/repo//src///index.ts", "/repo/src/index.ts"},
136
+ {"/repo/src/../src/index.ts", "/repo/src/index.ts"},
137
+ {"./relative/path", "relative/path"},
138
+ }
139
+ for _, tc := range cases {
140
+ if got := normalizePath(tc.input); got != tc.want {
141
+ t.Fatalf("normalizePath(%q) = %q, want %q", tc.input, got, tc.want)
142
+ }
143
+ }
144
+ }
145
+
146
+ // Verifies isOutsideRelativePath flags any relative path that escapes the
147
+ // root via `..` segments.
148
+ //
149
+ // `pathRewriter.outputForSource` refuses to map sources that resolve
150
+ // outside `rootDir`. Without this guard, a controller imported from a
151
+ // monorepo neighbor would emit into the outDir at a relative position
152
+ // that collides with an unrelated file.
153
+ //
154
+ // 1. Bare `..` is outside.
155
+ // 2. `../...` prefix is outside.
156
+ // 3. Same-or-nested paths are inside.
157
+ func TestIsOutsideRelativePathRejectsParentEscapes(t *testing.T) {
158
+ cases := []struct {
159
+ rel string
160
+ want bool
161
+ }{
162
+ {"..", true},
163
+ {"../sibling", true},
164
+ {"../../escape", true},
165
+ {".", false},
166
+ {"nested/file.ts", false},
167
+ {"sub/../same.ts", false},
168
+ }
169
+ for _, tc := range cases {
170
+ if got := isOutsideRelativePath(tc.rel); got != tc.want {
171
+ t.Fatalf("isOutsideRelativePath(%q) = %v, want %v", tc.rel, got, tc.want)
172
+ }
173
+ }
174
+ }
175
+
176
+ // Verifies stripKnownSourceExtension prefers the longest known suffix and
177
+ // falls back to filepath.Ext for unknown extensions.
178
+ //
179
+ // The declaration-file extensions (`.d.ts`, `.d.mts`, `.d.cts`) must be
180
+ // matched as a whole — falling through to the simpler `.ts` strip would
181
+ // leave the `.d` suffix on the stem and silently produce wrong virtual
182
+ // paths for declaration emitters.
183
+ //
184
+ // 1. `.d.ts` strips the full three-character suffix.
185
+ // 2. Each one-extension form (`.ts`, `.tsx`, `.mts`, …) strips cleanly.
186
+ // 3. Unknown extensions fall back to filepath.Ext.
187
+ // 4. Case-insensitive match on uppercase variants.
188
+ func TestStripKnownSourceExtensionPrefersLongestMatch(t *testing.T) {
189
+ cases := []struct {
190
+ input string
191
+ want string
192
+ }{
193
+ {"types.d.ts", "types"},
194
+ {"types.d.mts", "types"},
195
+ {"types.d.cts", "types"},
196
+ {"index.ts", "index"},
197
+ {"index.tsx", "index"},
198
+ {"index.mts", "index"},
199
+ {"index.cts", "index"},
200
+ {"index.js", "index"},
201
+ {"data.json", "data"},
202
+ {"Index.D.TS", "Index"},
203
+ {"README", "README"},
204
+ }
205
+ for _, tc := range cases {
206
+ if got := stripKnownSourceExtension(tc.input); got != tc.want {
207
+ t.Fatalf("stripKnownSourceExtension(%q) = %q, want %q", tc.input, got, tc.want)
208
+ }
209
+ }
210
+ }
211
+
212
+ // Verifies replaceSourceExtension swaps the known TypeScript extension
213
+ // while preserving the rest of the path.
214
+ //
215
+ // Combined with `emittedJavaScriptExtension`, this drives the source ↔
216
+ // output filename mapping the rewrite scan depends on. The declaration
217
+ // case is load-bearing: `stripKnownSourceExtension` must prefer the full
218
+ // `.d.ts` suffix over a plain `.ts` strip, otherwise `types.d.ts +
219
+ // .d.ts` would silently become `types..d.ts` and the declaration
220
+ // emitter would write a corrupt path.
221
+ //
222
+ // 1. `.ts` is replaced with the chosen output extension.
223
+ // 2. Declaration suffixes (`.d.ts`) strip wholly before replacement.
224
+ // 3. POSIX separators in the input flow through unchanged.
225
+ // 4. `.d.ts` + `.d.ts` stays `.d.ts` (pins the longest-match prefer rule).
226
+ func TestReplaceSourceExtensionSwapsKnownSuffix(t *testing.T) {
227
+ cases := []struct {
228
+ input string
229
+ ext string
230
+ want string
231
+ }{
232
+ {"src/index.ts", ".js", "src/index.js"},
233
+ {"src/index.mts", ".mjs", "src/index.mjs"},
234
+ {"src/types.d.ts", ".js", "src/types.js"},
235
+ {"src/nested/foo.ts", ".js", "src/nested/foo.js"},
236
+ {"src/types.d.ts", ".d.ts", "src/types.d.ts"},
237
+ }
238
+ for _, tc := range cases {
239
+ if got := replaceSourceExtension(tc.input, tc.ext); got != tc.want {
240
+ t.Fatalf("replaceSourceExtension(%q, %q) = %q, want %q", tc.input, tc.ext, got, tc.want)
241
+ }
242
+ }
243
+ }
@@ -1,4 +1,4 @@
1
- package main
1
+ package transform
2
2
 
3
3
  import (
4
4
  shimast "github.com/microsoft/typescript-go/shim/ast"
@@ -1,4 +1,4 @@
1
- package main
1
+ package transform
2
2
 
3
3
  import (
4
4
  "fmt"
@@ -163,7 +163,7 @@ func (rs *nativeRewriteSet) findSourceForOutput(outputName string) (string, bool
163
163
  outSlash := strings.TrimSuffix(filepath.ToSlash(outputName), filepath.Ext(outputName))
164
164
  for path := range rs.byPath {
165
165
  srcStem := strings.TrimSuffix(filepath.ToSlash(path), filepath.Ext(path))
166
- if outputMatchesSourceStem(outSlash, srcStem) {
166
+ if OutputMatchesSourceStem(outSlash, srcStem) {
167
167
  return path, true
168
168
  }
169
169
  }
@@ -208,7 +208,7 @@ func outputHasBuildMarker(stem string) bool {
208
208
  return false
209
209
  }
210
210
 
211
- func outputMatchesSourceStem(outputStem string, sourceStem string) bool {
211
+ func OutputMatchesSourceStem(outputStem string, sourceStem string) bool {
212
212
  if outputStem == sourceStem {
213
213
  return true
214
214
  }
@@ -0,0 +1,118 @@
1
+ package transform
2
+
3
+ import "testing"
4
+
5
+ // Verifies OutputMatchesSourceStem accepts virtual-fs same-stem mirrors and
6
+ // rejects single-segment suffix collisions across the source tree.
7
+ //
8
+ // Regression: a top-level `src/index.ts` produced a one-segment relative
9
+ // ("index") that the HasSuffix fallback matched against every output ending
10
+ // in `/index.js`. The native rewrite scan then trawled unrelated files for
11
+ // typia call sites that only existed in the root entry, raising
12
+ // "could not locate typia.reflect.schemas(...) call".
13
+ //
14
+ // 1. Mirror a src/index virtual fs — must match.
15
+ // 2. Compare top-level src/index against nested src/api/health/index — must reject.
16
+ // 3. Cover the package src->lib mapping and an unrelated stem pair.
17
+ func TestOutputMatchesSourceStem(t *testing.T) {
18
+ cases := []struct {
19
+ name string
20
+ output string
21
+ source string
22
+ want bool
23
+ }{
24
+ {
25
+ name: "virtual fs mirrors src tree",
26
+ output: "/cache/.ttsc/project/1/fs/posix/repo/tests/foo/src/index",
27
+ source: "/repo/tests/foo/src/index",
28
+ want: true,
29
+ },
30
+ {
31
+ name: "top-level src/index does not match nested src/api/health/index",
32
+ output: "/cache/.ttsc/project/1/fs/posix/repo/tests/foo/src/api/functional/health/index",
33
+ source: "/repo/tests/foo/src/index",
34
+ want: false,
35
+ },
36
+ {
37
+ name: "src/api source matches nested api output",
38
+ output: "/cache/.ttsc/project/1/fs/posix/repo/tests/foo/src/api/functional/health/index",
39
+ source: "/repo/tests/foo/src/api/functional/health/index",
40
+ want: true,
41
+ },
42
+ {
43
+ name: "package src maps to lib output",
44
+ output: "/repo/packages/core/lib/decorators/TypedBody",
45
+ source: "/repo/packages/core/src/decorators/TypedBody",
46
+ want: true,
47
+ },
48
+ {
49
+ name: "unrelated stems",
50
+ output: "/repo/packages/core/lib/decorators/TypedBody",
51
+ source: "/repo/packages/sdk/src/generates/Foo",
52
+ want: false,
53
+ },
54
+ {
55
+ name: "top-level src/index does not match nested src/api/index",
56
+ output: "/cache/.ttsc/project/1/fs/posix/repo/tests/foo/src/api/index",
57
+ source: "/repo/tests/foo/src/index",
58
+ want: false,
59
+ },
60
+ }
61
+ for _, tc := range cases {
62
+ got := OutputMatchesSourceStem(tc.output, tc.source)
63
+ if got != tc.want {
64
+ t.Errorf("%s: OutputMatchesSourceStem(%q, %q) = %v, want %v",
65
+ tc.name, tc.output, tc.source, got, tc.want)
66
+ }
67
+ }
68
+ }
69
+
70
+ func TestCountNativeArgumentsBasic(t *testing.T) {
71
+ cases := []struct {
72
+ name string
73
+ in string
74
+ want int
75
+ }{
76
+ {"empty", "", 0},
77
+ {"single", "x", 1},
78
+ {"two simple", "a, b", 2},
79
+ {"three with object", "a, { b: 1, c: 2 }, d", 3},
80
+ {"comma in string", `"a, b", c`, 2},
81
+ {"comma in single quote", `'a, b', c, d`, 3},
82
+ {"escaped quote", `"a\"b", c`, 2},
83
+ {"comma in template", "`a,b`, c", 2},
84
+ {"nested call", "foo(a, b), c", 2},
85
+ {"nested array", "[1, 2, 3], y", 2},
86
+ }
87
+ for _, tc := range cases {
88
+ got := countNativeArguments(tc.in)
89
+ if got != tc.want {
90
+ t.Errorf("%s: countNativeArguments(%q) = %d, want %d", tc.name, tc.in, got, tc.want)
91
+ }
92
+ }
93
+ }
94
+
95
+ // Regression: template-literal `${...}` substitutions contain real expressions
96
+ // — any '(' / ')' / ',' inside a substitution must be treated as expression
97
+ // tokens, not template-string content. Prior implementation skipped the entire
98
+ // backtick range as if it were a flat string and miscounted arguments when a
99
+ // substitution contained quote-shaped characters or nested templates.
100
+ func TestCountNativeArgumentsTemplateSubstitution(t *testing.T) {
101
+ cases := []struct {
102
+ name string
103
+ in string
104
+ want int
105
+ }{
106
+ {"quote-inside-substitution", "`x${\")\"}y`, foo", 2},
107
+ {"comma-inside-substitution-grouped", "`x${(1, 2)}y`, foo", 2},
108
+ {"nested-template", "`a${`b${1}c`}d`, foo", 2},
109
+ {"substitution-with-object", "`x${{ a: 1, b: 2 }}y`, foo", 2},
110
+ {"call-inside-substitution", "`x${ f(1, 2) }y`, foo", 2},
111
+ }
112
+ for _, tc := range cases {
113
+ got := countNativeArguments(tc.in)
114
+ if got != tc.want {
115
+ t.Errorf("%s: countNativeArguments(%q) = %d, want %d", tc.name, tc.in, got, tc.want)
116
+ }
117
+ }
118
+ }