@smoothbricks/lmao-ttsc 0.0.0-bootstrap.0 → 0.1.7

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.
package/plugin/main.go ADDED
@@ -0,0 +1,572 @@
1
+ // ttsc transform plugin for LMAO — Go port of spec 01o (smoo/lmao!n/transformer).
2
+ //
3
+ // Implements the structural (TypeChecker-free) transformations:
4
+ //
5
+ // §1/§2 span() line injection + monomorphic spanN rewrite (heuristic Op detection)
6
+ // §5 defineModule() metadata injection (git_sha, package_name, package_file)
7
+ // §6 .line(N) injection on log/ok/err chains
8
+ // §7 task('name', fn) line injection
9
+ //
10
+ // NOT yet ported (staged, requires the tsgo Checker via driver shims):
11
+ //
12
+ // §3 destructured-context rewriting (shipped in the TS transformer;
13
+ // needs identifier-binding analysis parity before porting)
14
+ // §4 tag-chain inlining with schema specialization (enum indices,
15
+ // eager/lazy null-bitmap elision). The checker-free fallback of §4
16
+ // is deliberately NOT ported either: emitting direct buffer writes
17
+ // without the schema risks divergence from the TS inliner's output;
18
+ // run the classic transformer for tag inlining until the Checker
19
+ // port lands.
20
+ //
21
+ // Column-name contract (spec 01e): any future §4 port must write
22
+ // library-local (unprefixed) column names; prefix/mapColumns remapping is
23
+ // cold-path-only via RemappedBufferView and must never appear in emitted
24
+ // hot-path writes.
25
+ package main
26
+
27
+ import (
28
+ "encoding/json"
29
+ "fmt"
30
+ "os"
31
+ "os/exec"
32
+ "path/filepath"
33
+ "strconv"
34
+ "strings"
35
+
36
+ shimast "github.com/microsoft/typescript-go/shim/ast"
37
+ shimchecker "github.com/microsoft/typescript-go/shim/checker"
38
+ shimprinter "github.com/microsoft/typescript-go/shim/printer"
39
+ shimscanner "github.com/microsoft/typescript-go/shim/scanner"
40
+ "github.com/samchon/ttsc/packages/ttsc/driver"
41
+ )
42
+
43
+ const pluginName = "@smoothbricks/lmao-ttsc"
44
+ const pluginVersion = "0.1.6"
45
+
46
+ func main() {
47
+ if len(os.Args) < 2 {
48
+ fmt.Fprintf(os.Stderr, "%s: command required\n", pluginName)
49
+ os.Exit(2)
50
+ }
51
+ switch os.Args[1] {
52
+ case "version", "-v", "--version":
53
+ fmt.Printf("%s %s\n", pluginName, pluginVersion)
54
+ case "check":
55
+ // Transform-stage plugin: check is a no-op.
56
+ case "transform":
57
+ os.Exit(runTransform(os.Args[2:]))
58
+ case "build":
59
+ os.Exit(runBuild(os.Args[2:]))
60
+ default:
61
+ fmt.Fprintf(os.Stderr, "%s: unknown command %q\n", pluginName, os.Args[1])
62
+ os.Exit(2)
63
+ }
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Host plumbing (per ttsc authoring walkthrough)
68
+ // ---------------------------------------------------------------------------
69
+
70
+ func readFlags(args []string) (cwd, tsconfig string) {
71
+ tsconfig = "tsconfig.json"
72
+ for _, a := range args {
73
+ switch {
74
+ case strings.HasPrefix(a, "--cwd="):
75
+ cwd = strings.TrimPrefix(a, "--cwd=")
76
+ case strings.HasPrefix(a, "--tsconfig="):
77
+ tsconfig = strings.TrimPrefix(a, "--tsconfig=")
78
+ }
79
+ }
80
+ if cwd == "" {
81
+ cwd, _ = os.Getwd()
82
+ }
83
+ if !filepath.IsAbs(tsconfig) {
84
+ tsconfig = filepath.Join(cwd, tsconfig)
85
+ }
86
+ return cwd, tsconfig
87
+ }
88
+
89
+ type transformResult struct {
90
+ TypeScript map[string]string `json:"typescript"`
91
+ }
92
+
93
+ func outputKey(cwd, fileName string) string {
94
+ rel, err := filepath.Rel(cwd, fileName)
95
+ if err != nil {
96
+ return fileName
97
+ }
98
+ return filepath.ToSlash(rel)
99
+ }
100
+
101
+ // lmaoPluginTransform builds the emit-phase transformer. Running inside
102
+ // tsgo's per-file emit chain (the AST-integration model, same as typia) is
103
+ // what makes synthesized pos:-1 nodes safe: the checker's per-file emit-time
104
+ // passes (grammar checks, MarkLinkedReferences) run BEFORE the script
105
+ // transformers, so nothing type-checks the mutated tree. Checker queries the
106
+ // transform itself makes (tag-chain detection) happen in a collect phase over
107
+ // the still-untouched file before any node is spliced.
108
+ func lmaoPluginTransform(prog *driver.Program, cwd string) driver.PluginTransform {
109
+ return func(_ *shimprinter.EmitContext, sf *shimast.SourceFile) *shimast.SourceFile {
110
+ if sf == nil || sf.IsDeclarationFile {
111
+ return sf
112
+ }
113
+ t := &fileTransformer{
114
+ file: sf, cwd: cwd, checker: prog.Checker,
115
+ processed: map[*shimast.CallExpression]bool{},
116
+ opSpans: map[*shimast.CallExpression]bool{},
117
+ logTemplateIDs: map[*shimast.CallExpression]uint16{},
118
+ }
119
+ hintRewrites := t.collectOptimizations(sf.AsNode())
120
+ tagInlines, logInlines, resultInlines := t.collectTagInlines(sf.AsNode())
121
+ applyHintRewrites(hintRewrites)
122
+ t.applyTagInlines(tagInlines)
123
+ t.applyLogInlines(logInlines)
124
+ t.applyResultInlines(resultInlines)
125
+ t.walk(sf.AsNode())
126
+ // Wire Parent on synthesized nodes (only where nil); printer passes
127
+ // walk parents. See shim/ast/parent.go.
128
+ shimast.SetParentInChildrenUnset(sf.AsNode())
129
+ return sf
130
+ }
131
+ }
132
+
133
+ func runTransform(args []string) int {
134
+ cwd, tsconfig := readFlags(args)
135
+ prog, _, err := driver.LoadProgram(cwd, tsconfig, driver.LoadProgramOptions{})
136
+ if err != nil {
137
+ fmt.Fprintf(os.Stderr, "%s: %v\n", pluginName, err)
138
+ return 2
139
+ }
140
+ defer prog.Close()
141
+
142
+ transform := lmaoPluginTransform(prog, cwd)
143
+ out := transformResult{TypeScript: map[string]string{}}
144
+ for _, file := range prog.SourceFiles() {
145
+ if file == nil || file.IsDeclarationFile {
146
+ continue
147
+ }
148
+ ec := shimprinter.NewEmitContext()
149
+ result := file
150
+ if next := transform(ec, result); next != nil {
151
+ result = next
152
+ }
153
+ printer := shimprinter.NewPrinter(shimprinter.PrinterOptions{}, shimprinter.PrintHandlers{}, ec)
154
+ out.TypeScript[outputKey(cwd, file.FileName())] = shimprinter.EmitSourceFile(printer, result)
155
+ }
156
+ data, err := json.Marshal(out)
157
+ if err != nil {
158
+ fmt.Fprintf(os.Stderr, "%s: marshal failed: %v\n", pluginName, err)
159
+ return 3
160
+ }
161
+ fmt.Println(string(data))
162
+ return 0
163
+ }
164
+
165
+ func runBuild(args []string) int {
166
+ cwd, tsconfig := readFlags(args)
167
+ prog, _, err := driver.LoadProgram(cwd, tsconfig, driver.LoadProgramOptions{})
168
+ if err != nil {
169
+ fmt.Fprintf(os.Stderr, "%s: %v\n", pluginName, err)
170
+ return 2
171
+ }
172
+ defer prog.Close()
173
+
174
+ emitDiags, err := prog.EmitWithPluginTransformers(
175
+ []driver.PluginTransform{lmaoPluginTransform(prog, cwd)}, nil)
176
+ if err != nil {
177
+ fmt.Fprintf(os.Stderr, "%s: emit failed: %v\n", pluginName, err)
178
+ return 3
179
+ }
180
+ if len(emitDiags) > 0 {
181
+ for _, d := range emitDiags {
182
+ fmt.Fprintln(os.Stderr, d.String())
183
+ }
184
+ return 2
185
+ }
186
+ return 0
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Node synthesis helpers (shared factory; synthesized nodes carry pos -1)
191
+ // ---------------------------------------------------------------------------
192
+
193
+ var factory = shimast.NewNodeFactory(shimast.NodeFactoryHooks{})
194
+
195
+ // Factory-built nodes keep their default undefined (-1) text range: that is
196
+ // what makes the printer emit .Text instead of a source span. The tsgo
197
+ // CHECKER, however, panics on pos -1 (checkGrammarNumericLiteral →
198
+ // GetTextOfNodeFromSourceText slices source text) — so the invariant here is
199
+ // ORDERING, not positions: every Checker query happens in a detection pass
200
+ // over the untouched parse tree, and synthesized nodes are only spliced in
201
+ // afterwards, where nothing type-checks them again.
202
+
203
+ func ident(text string) *shimast.Node {
204
+ return factory.NewIdentifier(text)
205
+ }
206
+
207
+ func str(text string) *shimast.Node {
208
+ return factory.NewStringLiteral(text, shimast.TokenFlagsNone)
209
+ }
210
+
211
+ func num(n int) *shimast.Node {
212
+ return factory.NewNumericLiteral(strconv.Itoa(n), shimast.TokenFlagsNone)
213
+ }
214
+
215
+ func propAccess(expr *shimast.Node, name string) *shimast.Node {
216
+ return factory.NewPropertyAccessExpression(expr, nil, ident(name), shimast.NodeFlagsNone)
217
+ }
218
+
219
+ func callExpr(expr *shimast.Node, args []*shimast.Node) *shimast.Node {
220
+ return factory.NewCallExpression(expr, nil, nil, factory.NewNodeList(args), shimast.NodeFlagsNone)
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Transformations
225
+ // ---------------------------------------------------------------------------
226
+
227
+ var logMethods = map[string]bool{"info": true, "debug": true, "warn": true, "error": true, "trace": true}
228
+ var resultMethods = map[string]bool{"ok": true, "err": true}
229
+
230
+ type fileTransformer struct {
231
+ file *shimast.SourceFile
232
+ cwd string
233
+ // seenDefineModule guards the one-module-per-file invariant (spec 01o §5).
234
+ seenDefineModule bool
235
+ // processed marks call nodes produced by a chain rewrite so the walker
236
+ // does not re-match them (the Go analog of the TS transformer's
237
+ // processedCalls WeakSet). Without it, the cloned inner call re-matches
238
+ // on descent and the rewrite regresses infinitely.
239
+ processed map[*shimast.CallExpression]bool
240
+ // opSpans is populated with Checker-proved, stable-expression Op calls in
241
+ // the untouched-tree collect phase. The mutation walk never queries types.
242
+ opSpans map[*shimast.CallExpression]bool
243
+ // logTemplateIDs is populated by checker/provenance-gated per-Op analysis
244
+ // and consumed only after all checker-backed collection has completed.
245
+ logTemplateIDs map[*shimast.CallExpression]uint16
246
+ // checker is the program's pinned type checker (nil-safe: tag-chain
247
+ // inlining is skipped entirely without it).
248
+ checker *shimchecker.Checker
249
+ }
250
+
251
+ func (t *fileTransformer) walk(node *shimast.Node) {
252
+ if node == nil {
253
+ return
254
+ }
255
+ if node.Kind == shimast.KindCallExpression {
256
+ call := node.AsCallExpression()
257
+ switch {
258
+ case t.tryDefineModuleMetadata(call):
259
+ case t.processed[call]:
260
+ case t.trySpanRewrite(call):
261
+ case t.tryTaskLine(call):
262
+ case t.tryChainLine(call, logMethods, isLogReceiver):
263
+ case t.tryChainLine(call, resultMethods, nil):
264
+ }
265
+ }
266
+ node.ForEachChild(func(child *shimast.Node) bool {
267
+ t.walk(child)
268
+ return false
269
+ })
270
+ }
271
+
272
+ // lineOf returns the 1-based line of a node's trivia-skipped start position
273
+ // (parity with the TS transformer's getStart()-based getLineNumber).
274
+ func (t *fileTransformer) lineOf(node *shimast.Node) int {
275
+ pos := shimscanner.SkipTrivia(t.file.Text(), node.Pos())
276
+ return shimscanner.GetECMALineOfPosition(t.file, pos) + 1
277
+ }
278
+
279
+ // calleeNames returns the receiver and method name for `recv.name(...)`,
280
+ // or (nil, text) for a bare `name(...)`.
281
+ func calleeNames(call *shimast.CallExpression) (recv *shimast.Node, name string) {
282
+ expr := call.Expression
283
+ if expr.Kind == shimast.KindPropertyAccessExpression {
284
+ pa := expr.AsPropertyAccessExpression()
285
+ return pa.Expression, shimast.NodeText(pa.Name())
286
+ }
287
+ if expr.Kind == shimast.KindIdentifier {
288
+ return nil, shimast.NodeText(expr)
289
+ }
290
+ return nil, ""
291
+ }
292
+
293
+ // --- §5 defineModule metadata ------------------------------------------------
294
+
295
+ func (t *fileTransformer) tryDefineModuleMetadata(call *shimast.CallExpression) bool {
296
+ _, name := calleeNames(call)
297
+ if name != "defineModule" || len(call.Arguments.Nodes) == 0 {
298
+ return false
299
+ }
300
+ arg := call.Arguments.Nodes[0]
301
+ if arg.Kind != shimast.KindObjectLiteralExpression {
302
+ return false
303
+ }
304
+ obj := arg.AsObjectLiteralExpression()
305
+ if t.seenDefineModule {
306
+ fmt.Fprintf(os.Stderr, "%s: invariant violation: %s contains multiple defineModule() declarations\n",
307
+ pluginName, t.file.FileName())
308
+ os.Exit(2)
309
+ }
310
+ t.seenDefineModule = true
311
+ for _, prop := range obj.Properties.Nodes {
312
+ if prop.Kind == shimast.KindPropertyAssignment && prop.Name() != nil &&
313
+ shimast.NodeText(prop.Name()) == "metadata" {
314
+ return false // already has metadata — leave alone
315
+ }
316
+ }
317
+
318
+ gitSha := gitLastCommit(t.file.FileName(), t.cwd)
319
+ pkgName, pkgFile := nearestPackage(t.file.FileName())
320
+
321
+ metaProps := []*shimast.Node{
322
+ factory.NewPropertyAssignment(nil, ident("git_sha"), nil, nil, str(gitSha)),
323
+ factory.NewPropertyAssignment(nil, ident("package_name"), nil, nil, str(pkgName)),
324
+ factory.NewPropertyAssignment(nil, ident("package_file"), nil, nil, str(pkgFile)),
325
+ }
326
+ metaObj := factory.NewObjectLiteralExpression(factory.NewNodeList(metaProps), true)
327
+ metadata := factory.NewPropertyAssignment(nil, ident("metadata"), nil, nil, metaObj)
328
+
329
+ obj.Properties = factory.NewNodeList(append([]*shimast.Node{metadata}, obj.Properties.Nodes...))
330
+ return true
331
+ }
332
+
333
+ func gitLastCommit(filePath, cwd string) string {
334
+ rel, err := filepath.Rel(cwd, filePath)
335
+ if err != nil {
336
+ return "unknown"
337
+ }
338
+ out, err := exec.Command("git", "-C", cwd, "rev-list", "-1", "HEAD", "--", filepath.ToSlash(rel)).Output()
339
+ if err != nil {
340
+ return "unknown"
341
+ }
342
+ sha := strings.TrimSpace(string(out))
343
+ if sha == "" {
344
+ return "unknown"
345
+ }
346
+ return sha
347
+ }
348
+
349
+ func nearestPackage(filePath string) (name, relFile string) {
350
+ dir := filepath.Dir(filePath)
351
+ root := filepath.VolumeName(dir) + string(filepath.Separator)
352
+ for dir != root {
353
+ data, err := os.ReadFile(filepath.Join(dir, "package.json"))
354
+ if err == nil {
355
+ var pkg struct {
356
+ Name string `json:"name"`
357
+ }
358
+ if json.Unmarshal(data, &pkg) == nil && pkg.Name != "" {
359
+ rel, err := filepath.Rel(dir, filePath)
360
+ if err != nil {
361
+ rel = filepath.Base(filePath)
362
+ }
363
+ return pkg.Name, filepath.ToSlash(rel)
364
+ }
365
+ }
366
+ dir = filepath.Dir(dir)
367
+ }
368
+ return "unknown", filepath.Base(filePath)
369
+ }
370
+
371
+ // --- §1/§2 span rewrite --------------------------------------------------------
372
+
373
+ func (t *fileTransformer) trySpanRewrite(call *shimast.CallExpression) bool {
374
+ recv, name := calleeNames(call)
375
+ if name != "span" || recv == nil || len(call.Arguments.Nodes) < 2 || len(call.Arguments.Nodes) > 10 {
376
+ return false
377
+ }
378
+ args := call.Arguments.Nodes
379
+ nameArg, opOrFn := args[0], args[1]
380
+ rest := args[2:]
381
+
382
+ if recv.Kind != shimast.KindIdentifier && recv.Kind != shimast.KindThisKeyword {
383
+ return false
384
+ }
385
+ isPlainFunction := opOrFn.Kind == shimast.KindArrowFunction || opOrFn.Kind == shimast.KindFunctionExpression
386
+ if !isPlainFunction && !t.opSpans[call] {
387
+ return false
388
+ }
389
+
390
+ line := t.lineOf(call.AsNode())
391
+ methodName := fmt.Sprintf("span%d", len(rest))
392
+ childCtx := callExpr(propAccess(ident("Object"), "create"), []*shimast.Node{recv})
393
+ var bufferClass, remappedView, opMetadata, fn, runtimeHint *shimast.Node
394
+ if isPlainFunction {
395
+ bufferClass = propAccess(propAccess(recv, "_buffer"), "constructor")
396
+ remappedView = ident("undefined")
397
+ opMetadata = propAccess(propAccess(recv, "_buffer"), "_opMetadata")
398
+ fn = opOrFn
399
+ runtimeHint = num(0)
400
+ } else {
401
+ bufferClass = propAccess(opOrFn, "SpanBufferClass")
402
+ remappedView = propAccess(opOrFn, "remappedViewClass")
403
+ opMetadata = propAccess(opOrFn, "metadata")
404
+ fn = propAccess(opOrFn, "fn")
405
+ runtimeHint = propAccess(opOrFn, "runtimeHint")
406
+ }
407
+ newArgs := append([]*shimast.Node{
408
+ num(line), nameArg, childCtx, bufferClass, remappedView, opMetadata, fn,
409
+ }, rest...)
410
+ newArgs = append(newArgs, runtimeHint)
411
+
412
+ call.Expression = propAccess(recv, methodName)
413
+ call.Arguments = factory.NewNodeList(newArgs)
414
+ return true
415
+ }
416
+
417
+ // --- §7 task line ---------------------------------------------------------------
418
+
419
+ func (t *fileTransformer) tryTaskLine(call *shimast.CallExpression) bool {
420
+ _, name := calleeNames(call)
421
+ if name != "task" || len(call.Arguments.Nodes) != 2 {
422
+ return false
423
+ }
424
+ if call.Arguments.Nodes[0].Kind != shimast.KindStringLiteral {
425
+ return false
426
+ }
427
+ call.Arguments = factory.NewNodeList(append(
428
+ append([]*shimast.Node{}, call.Arguments.Nodes...),
429
+ num(t.lineOf(call.AsNode())),
430
+ ))
431
+ return true
432
+ }
433
+
434
+ // --- §6 log / result .line(N) -----------------------------------------------------
435
+
436
+ // isLogReceiver checks the receiver of a log method is a `.log` property access.
437
+ func isLogReceiver(recv *shimast.Node) bool {
438
+ return recv != nil &&
439
+ recv.Kind == shimast.KindPropertyAccessExpression &&
440
+ shimast.NodeText(recv.AsPropertyAccessExpression().Name()) == "log"
441
+ }
442
+
443
+ // tryChainLine inserts `.line(N)` right after the matched method at the root
444
+ // of a fluent chain, preserving trailing calls; no-op when `.line` is present.
445
+ func (t *fileTransformer) tryChainLine(call *shimast.CallExpression, methods map[string]bool, receiverOK func(*shimast.Node) bool) bool {
446
+ if t.processed[call] {
447
+ return false
448
+ }
449
+ // Only fire at the TOP of a fluent chain: a call that is itself the
450
+ // receiver of an enclosing method call belongs to a larger chain whose
451
+ // top the walker handles (or already handled). Injecting mid-chain
452
+ // duplicates .line() (the TS transformer prevents this with its
453
+ // processedCalls sweep over allCallsInChain).
454
+ if parent := call.AsNode().Parent; parent != nil && parent.Kind == shimast.KindPropertyAccessExpression {
455
+ if gp := parent.Parent; gp != nil && gp.Kind == shimast.KindCallExpression &&
456
+ gp.AsCallExpression().Expression == parent {
457
+ return false
458
+ }
459
+ }
460
+ target, trailing := findChainTarget(call, methods, receiverOK)
461
+ templateID := uint16(0)
462
+ if receiverOK != nil {
463
+ if templateTarget, templateTrailing, id := findTemplateLogInChain(call, t.logTemplateIDs); templateTarget != nil {
464
+ target, trailing, templateID = templateTarget, templateTrailing, id
465
+ }
466
+ }
467
+ if target == nil {
468
+ return false
469
+ }
470
+ hasLine := chainHasLine(call)
471
+ if hasLine && templateID == 0 {
472
+ return false
473
+ }
474
+ line := t.lineOf(target.AsNode())
475
+
476
+ // Build a fresh inner call, substituting the private template entry point
477
+ // when this exact call was assigned an Op-local ID during collection.
478
+ innerExpression := target.Expression
479
+ innerArguments := target.Arguments
480
+ if templateID != 0 {
481
+ pa := target.Expression.AsPropertyAccessExpression()
482
+ innerExpression = propAccess(pa.Expression, "_"+shimast.NodeText(pa.Name())+"Template")
483
+ innerArguments = factory.NewNodeList([]*shimast.Node{num(int(templateID))})
484
+ }
485
+ inner := factory.NewCallExpression(
486
+ innerExpression, nil, target.TypeArguments, innerArguments, shimast.NodeFlagsNone,
487
+ )
488
+ t.processed[inner.AsCallExpression()] = true
489
+ t.processed[call] = true
490
+ var rebuilt *shimast.Node
491
+ if hasLine {
492
+ rebuilt = inner
493
+ } else {
494
+ rebuilt = callExpr(propAccess(inner, "line"), []*shimast.Node{num(line)})
495
+ }
496
+ for _, link := range trailing {
497
+ rebuilt = callExpr(propAccess(rebuilt, link.name), factory.NewNodeList(link.args).Nodes)
498
+ }
499
+ rc := rebuilt.AsCallExpression()
500
+ call.Expression = rc.Expression
501
+ call.Arguments = rc.Arguments
502
+ return true
503
+ }
504
+
505
+ // findTemplateLogInChain locates a previously analyzed literal call while
506
+ // preserving the same receiver-to-outer fluent link order as findChainTarget.
507
+ func findTemplateLogInChain(call *shimast.CallExpression, ids map[*shimast.CallExpression]uint16) (*shimast.CallExpression, []chainLink, uint16) {
508
+ var trailing []chainLink
509
+ current := call
510
+ for {
511
+ if id := ids[current]; id != 0 {
512
+ return current, trailing, id
513
+ }
514
+ expr := current.Expression
515
+ if expr.Kind != shimast.KindPropertyAccessExpression {
516
+ return nil, nil, 0
517
+ }
518
+ pa := expr.AsPropertyAccessExpression()
519
+ trailing = append([]chainLink{{name: shimast.NodeText(pa.Name()), args: current.Arguments.Nodes}}, trailing...)
520
+ if pa.Expression.Kind != shimast.KindCallExpression {
521
+ return nil, nil, 0
522
+ }
523
+ current = pa.Expression.AsCallExpression()
524
+ }
525
+ }
526
+
527
+ type chainLink struct {
528
+ name string
529
+ args []*shimast.Node
530
+ }
531
+
532
+ // findChainTarget walks receiver-wards from `call` looking for the first
533
+ // method whose name is in `methods` (and whose receiver passes receiverOK),
534
+ // collecting the trailing links crossed on the way.
535
+ func findChainTarget(call *shimast.CallExpression, methods map[string]bool, receiverOK func(*shimast.Node) bool) (*shimast.CallExpression, []chainLink) {
536
+ var trailing []chainLink
537
+ current := call
538
+ for {
539
+ expr := current.Expression
540
+ if expr.Kind != shimast.KindPropertyAccessExpression {
541
+ return nil, nil
542
+ }
543
+ pa := expr.AsPropertyAccessExpression()
544
+ name := shimast.NodeText(pa.Name())
545
+ if methods[name] && (receiverOK == nil || receiverOK(pa.Expression)) {
546
+ return current, trailing
547
+ }
548
+ trailing = append([]chainLink{{name: name, args: current.Arguments.Nodes}}, trailing...)
549
+ if pa.Expression.Kind != shimast.KindCallExpression {
550
+ return nil, nil
551
+ }
552
+ current = pa.Expression.AsCallExpression()
553
+ }
554
+ }
555
+
556
+ func chainHasLine(call *shimast.CallExpression) bool {
557
+ current := call
558
+ for {
559
+ expr := current.Expression
560
+ if expr.Kind != shimast.KindPropertyAccessExpression {
561
+ return false
562
+ }
563
+ pa := expr.AsPropertyAccessExpression()
564
+ if shimast.NodeText(pa.Name()) == "line" {
565
+ return true
566
+ }
567
+ if pa.Expression.Kind != shimast.KindCallExpression {
568
+ return false
569
+ }
570
+ current = pa.Expression.AsCallExpression()
571
+ }
572
+ }
@@ -0,0 +1,43 @@
1
+ // Unit tests for the pure transform logic, per ttsc's AGENTS.md §2.2 testing
2
+ // shape (one Go unit test for pure logic + one e2e spawning ttsc against a
3
+ // fixture — the e2e lives with the consumer fixture once ttsc is a dev dep).
4
+ //
5
+ // NOTE: cannot run in this repo (no Go toolchain in devenv); run in a
6
+ // Go-enabled environment: cd plugin && go test ./...
7
+ package main
8
+
9
+ import (
10
+ "path/filepath"
11
+ "testing"
12
+ )
13
+
14
+ func TestNearestPackage(t *testing.T) {
15
+ // This file lives inside @smoothbricks/lmao-ttsc.
16
+ abs, err := filepath.Abs("main.go")
17
+ if err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ name, rel := nearestPackage(abs)
21
+ if name != "@smoothbricks/lmao-ttsc" {
22
+ t.Fatalf("nearestPackage name = %q, want @smoothbricks/lmao-ttsc", name)
23
+ }
24
+ if rel != "plugin/main.go" {
25
+ t.Fatalf("nearestPackage rel = %q, want plugin/main.go", rel)
26
+ }
27
+ }
28
+
29
+ func TestGitLastCommitUnknownOutsideRepo(t *testing.T) {
30
+ if sha := gitLastCommit("/definitely/not/a/file.ts", "/tmp"); sha != "unknown" {
31
+ t.Fatalf("expected unknown, got %q", sha)
32
+ }
33
+ }
34
+
35
+ func TestReadFlags(t *testing.T) {
36
+ cwd, tsconfig := readFlags([]string{"--cwd=/x", "--tsconfig=custom.json"})
37
+ if cwd != "/x" {
38
+ t.Fatalf("cwd = %q", cwd)
39
+ }
40
+ if tsconfig != filepath.Join("/x", "custom.json") {
41
+ t.Fatalf("tsconfig = %q", tsconfig)
42
+ }
43
+ }