ttsc 0.15.4 → 0.15.6

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.
@@ -0,0 +1,995 @@
1
+ // Package main is a mechanical completeness auditor for the typescript-go
2
+ // shim (`packages/ttsc/shim/*`).
3
+ //
4
+ // Background. ttsc re-exports typescript-go's `internal/*` packages through the
5
+ // shim so plugin authors (typia, nestia, third-party rules) never touch Go
6
+ // internals. A missing re-export is an ttsc bug, not a plugin bug — yet they
7
+ // keep being discovered one reactive issue at a time (#217, #218, #220, #221,
8
+ // #226, #230). This tool computes the shortfall mechanically instead.
9
+ //
10
+ // The invariant. The shim should be TRANSITIVELY CLOSED over the operations of
11
+ // the types it already exposes. If the shim already aliases an upstream type T,
12
+ // then everything a plugin can *reach* starting from T should also be reachable
13
+ // through the shim. Three closure rules are checked:
14
+ //
15
+ // - ENUM every exported const whose type is an already-exposed enum type
16
+ // must be re-exported. (This is the `SignatureKindConstruct` class —
17
+ // #230 rule 1. `SignatureKind` is exposed but only `...Call` is, so
18
+ // `...Construct` is a deterministic gap.)
19
+ // - FUNC every exported package-level func whose params and results are all
20
+ // already-reachable types must be reachable. (A plugin holding those
21
+ // types could call it, but can't.)
22
+ // - ESCAPE every exported named type that appears in the signature of an
23
+ // already-exposed operation but is not itself aliased. (A plugin can
24
+ // obtain the value but cannot name its type.)
25
+ //
26
+ // What it deliberately does NOT find: UNEXPORTED helpers a plugin needs by name
27
+ // (e.g. `(*Checker).getMinArgumentCount`, #230 rule 2). Those are invisible to
28
+ // closure and must come from the consumer-demand scan. The audit prints the
29
+ // unexported method/func pool of exposed types as a triage list so the demand
30
+ // side has a bounded candidate set.
31
+ //
32
+ // Usage. This is its own Go module, so run it from its own directory (or via
33
+ // `pnpm --filter ttsc shim:audit [mode]`, which is what CI uses):
34
+ //
35
+ // cd packages/ttsc/tools/shim_audit
36
+ // go run . -anchor ../../shim/ast -shim ../../shim # human report
37
+ // go run . -md -anchor ../../shim/ast -shim ../../shim # markdown report
38
+ // go run . -fix -anchor ../../shim/ast -shim ../../shim # regenerate enum closure
39
+ // go run . -check -anchor ../../shim/ast -shim ../../shim # CI gate
40
+ //
41
+ // "Reachable" is computed from what the shim source textually references: every
42
+ // `inner<pkg>.Symbol` selector and every //go:linkname target name. That set is
43
+ // exactly the surface a plugin can name, regardless of how it was exposed
44
+ // (alias, wrapper, or linkname).
45
+ package main
46
+
47
+ import (
48
+ "bytes"
49
+ "encoding/json"
50
+ "flag"
51
+ "fmt"
52
+ "go/ast"
53
+ "go/format"
54
+ "go/parser"
55
+ "go/token"
56
+ "go/types"
57
+ "os"
58
+ "path/filepath"
59
+ "regexp"
60
+ "sort"
61
+ "strings"
62
+
63
+ "golang.org/x/tools/go/packages"
64
+ )
65
+
66
+ const internalPrefix = "github.com/microsoft/typescript-go/internal/"
67
+
68
+ // shimDirs maps each shim directory to its upstream internal package suffix.
69
+ // Kept explicit (rather than globbed) so a new shim dir is a conscious add.
70
+ var shimDirs = map[string]string{
71
+ "ast": "ast",
72
+ "bundled": "bundled",
73
+ "checker": "checker",
74
+ "compiler": "compiler",
75
+ "core": "core",
76
+ "diagnosticwriter": "diagnosticwriter",
77
+ "lsp": "lsp",
78
+ "parser": "parser",
79
+ "printer": "printer",
80
+ "scanner": "scanner",
81
+ "transformers": "transformers",
82
+ "tsoptions": "tsoptions",
83
+ "tspath": "tspath",
84
+ "vfs": "vfs",
85
+ "vfs/cachedvfs": "vfs/cachedvfs",
86
+ "vfs/osvfs": "vfs/osvfs",
87
+ }
88
+
89
+ // reachable holds, per upstream package suffix, the set of upstream symbol names
90
+ // the shim makes nameable (selector references + linkname targets).
91
+ type reachable map[string]map[string]bool
92
+
93
+ func (r reachable) has(pkg, name string) bool {
94
+ m := r[pkg]
95
+ return m != nil && m[name]
96
+ }
97
+ func (r reachable) add(pkg, name string) {
98
+ if r[pkg] == nil {
99
+ r[pkg] = map[string]bool{}
100
+ }
101
+ r[pkg][name] = true
102
+ }
103
+
104
+ // linknameRe captures the trailing symbol name of a go:linkname target like
105
+ // `...internal/checker.(*Checker).getMinArgumentCount` or `...checker.foo`.
106
+ var linknameRe = regexp.MustCompile(`(?m)^//go:linkname\s+\S+\s+` +
107
+ regexp.QuoteMeta("github.com/microsoft/typescript-go/internal/") +
108
+ `([A-Za-z0-9_/]+)\.(?:\(\*?[A-Za-z0-9_]+\)\.)?([A-Za-z0-9_]+)`)
109
+
110
+ // scanShimReachable parses every .go file under each shim dir and records the
111
+ // upstream symbols it references.
112
+ func scanShimReachable(shimRoot string) (reachable, error) {
113
+ r := reachable{}
114
+ fset := token.NewFileSet()
115
+ for dir := range shimDirs {
116
+ paths, _ := filepath.Glob(filepath.Join(shimRoot, dir, "*.go"))
117
+ for _, p := range paths {
118
+ src, err := os.ReadFile(p)
119
+ if err != nil {
120
+ return nil, err
121
+ }
122
+ // linkname directives live in comments, so scan raw text too.
123
+ for _, m := range linknameRe.FindAllStringSubmatch(string(src), -1) {
124
+ r.add(m[1], m[2])
125
+ }
126
+ f, err := parser.ParseFile(fset, p, src, parser.SkipObjectResolution)
127
+ if err != nil {
128
+ return nil, fmt.Errorf("%s: %w", p, err)
129
+ }
130
+ // alias map: local import name -> internal pkg suffix
131
+ alias := map[string]string{}
132
+ for _, imp := range f.Imports {
133
+ path := strings.Trim(imp.Path.Value, `"`)
134
+ if !strings.HasPrefix(path, internalPrefix) {
135
+ continue
136
+ }
137
+ suffix := strings.TrimPrefix(path, internalPrefix)
138
+ name := suffix[strings.LastIndex(suffix, "/")+1:]
139
+ if imp.Name != nil {
140
+ name = imp.Name.Name
141
+ }
142
+ alias[name] = suffix
143
+ }
144
+ ast.Inspect(f, func(n ast.Node) bool {
145
+ sel, ok := n.(*ast.SelectorExpr)
146
+ if !ok {
147
+ return true
148
+ }
149
+ id, ok := sel.X.(*ast.Ident)
150
+ if !ok {
151
+ return true
152
+ }
153
+ if suffix, ok := alias[id.Name]; ok {
154
+ r.add(suffix, sel.Sel.Name)
155
+ }
156
+ return true
157
+ })
158
+ }
159
+ }
160
+ return r, nil
161
+ }
162
+
163
+ // hasShimSource reports whether dir holds non-test Go source — i.e. an actual
164
+ // shim re-export package, not a test-only directory like ast/test (whose only
165
+ // *.go are *_test.go and which is not part of the re-export surface).
166
+ func hasShimSource(dir string) bool {
167
+ goFiles, _ := filepath.Glob(filepath.Join(dir, "*.go"))
168
+ for _, f := range goFiles {
169
+ if !strings.HasSuffix(f, "_test.go") {
170
+ return true
171
+ }
172
+ }
173
+ return false
174
+ }
175
+
176
+ // checkShimDirCoverage fails if any sub-directory of the shim root (at ANY
177
+ // depth) that contains non-test Go source is not registered in shimDirs. This
178
+ // keeps the audit's package list honest: a newly-added shim — including a
179
+ // NESTED package like vfs/osvfs that a non-recursive, immediate-children scan
180
+ // would miss — cannot escape the gate by omission.
181
+ func checkShimDirCoverage(shimRoot string) error {
182
+ var unmapped []string
183
+ var walk func(dir, rel string) error
184
+ walk = func(dir, rel string) error {
185
+ entries, err := os.ReadDir(dir)
186
+ if err != nil {
187
+ return err
188
+ }
189
+ for _, e := range entries {
190
+ if !e.IsDir() {
191
+ continue
192
+ }
193
+ childRel := e.Name()
194
+ if rel != "" {
195
+ childRel = rel + "/" + e.Name()
196
+ }
197
+ if _, ok := shimDirs[childRel]; !ok && hasShimSource(filepath.Join(dir, e.Name())) {
198
+ unmapped = append(unmapped, childRel)
199
+ }
200
+ if err := walk(filepath.Join(dir, e.Name()), childRel); err != nil {
201
+ return err
202
+ }
203
+ }
204
+ return nil
205
+ }
206
+ if err := walk(shimRoot, ""); err != nil {
207
+ return err
208
+ }
209
+ if len(unmapped) > 0 {
210
+ sort.Strings(unmapped)
211
+ return fmt.Errorf("shim dir(s) not registered in shimDirs (would escape the audit): %s\n"+
212
+ " add them to shimDirs in tools/shim_audit/main.go", strings.Join(unmapped, ", "))
213
+ }
214
+ return nil
215
+ }
216
+
217
+ // loadInner loads the upstream internal packages via go/types, anchored in a
218
+ // shim module that requires typescript-go (same trick gen_shims uses).
219
+ func loadInner(anchorDir string) (map[string]*packages.Package, error) {
220
+ var full []string
221
+ expected := map[string]bool{}
222
+ for _, suffix := range shimDirs {
223
+ full = append(full, internalPrefix+suffix)
224
+ expected[suffix] = true
225
+ }
226
+ loaded, err := packages.Load(&packages.Config{
227
+ Dir: anchorDir,
228
+ Mode: packages.LoadTypes | packages.NeedDeps | packages.NeedImports,
229
+ }, full...)
230
+ if err != nil {
231
+ return nil, err
232
+ }
233
+ out := map[string]*packages.Package{}
234
+ errored := map[string]string{}
235
+ for _, p := range loaded {
236
+ suffix := strings.TrimPrefix(p.PkgPath, internalPrefix)
237
+ if len(p.Errors) > 0 {
238
+ errored[suffix] = p.Errors[0].Error()
239
+ continue
240
+ }
241
+ out[suffix] = p
242
+ }
243
+ // A package that fails to load (or is missing entirely) must FAIL the audit,
244
+ // never be silently skipped — otherwise a load error in any environment turns
245
+ // the gate into a no-op that passes blind.
246
+ var failed []string
247
+ for suffix := range expected {
248
+ if _, ok := out[suffix]; ok {
249
+ continue
250
+ }
251
+ msg := errored[suffix]
252
+ if msg == "" {
253
+ msg = "did not load"
254
+ }
255
+ failed = append(failed, suffix+": "+msg)
256
+ }
257
+ if len(failed) > 0 {
258
+ sort.Strings(failed)
259
+ return nil, fmt.Errorf("%d shim package(s) failed to load (audit would be incomplete):\n %s",
260
+ len(failed), strings.Join(failed, "\n "))
261
+ }
262
+ return out, nil
263
+ }
264
+
265
+ // namedInfo returns the defining package suffix and name of a named OR
266
+ // alias-to-named type, when it is a typescript-go internal type. Handling
267
+ // *types.Alias matters: an enum declared `type PragmaKindFlags = uint8` (a Go
268
+ // type alias to a basic) gives its member consts an *types.Alias type, and a
269
+ // bare *types.Named assertion would drop them — leaving a partial re-export of
270
+ // that #230-class enum invisible to the zero-tolerance ENUM check.
271
+ func namedInfo(t types.Type) (pkgSuffix, name string, ok bool) {
272
+ var obj *types.TypeName
273
+ switch n := t.(type) {
274
+ case *types.Named:
275
+ obj = n.Obj()
276
+ case *types.Alias:
277
+ obj = n.Obj()
278
+ default:
279
+ return "", "", false
280
+ }
281
+ if obj.Pkg() == nil {
282
+ return "", "", false
283
+ }
284
+ path := obj.Pkg().Path()
285
+ if !strings.HasPrefix(path, internalPrefix) {
286
+ return "", "", false
287
+ }
288
+ return strings.TrimPrefix(path, internalPrefix), obj.Name(), true
289
+ }
290
+
291
+ // isReachable reports whether a plugin holding only shim-exposed types could
292
+ // name/produce a value of type t.
293
+ func isReachable(t types.Type, r reachable, seen map[types.Type]bool) bool {
294
+ if seen[t] {
295
+ return true
296
+ }
297
+ seen[t] = true
298
+ switch x := t.(type) {
299
+ case *types.Basic:
300
+ return true
301
+ case *types.Pointer:
302
+ return isReachable(x.Elem(), r, seen)
303
+ case *types.Slice:
304
+ return isReachable(x.Elem(), r, seen)
305
+ case *types.Array:
306
+ return isReachable(x.Elem(), r, seen)
307
+ case *types.Map:
308
+ return isReachable(x.Key(), r, seen) && isReachable(x.Elem(), r, seen)
309
+ case *types.Chan:
310
+ return isReachable(x.Elem(), r, seen)
311
+ case *types.Interface:
312
+ return true // error / empty interface in practice
313
+ case *types.Signature:
314
+ return tupleReachable(x.Params(), r) && tupleReachable(x.Results(), r)
315
+ case *types.TypeParam:
316
+ return false
317
+ case *types.Named:
318
+ o := x.Obj()
319
+ if o.Pkg() == nil {
320
+ return true // builtin error etc.
321
+ }
322
+ path := o.Pkg().Path()
323
+ if strings.HasPrefix(path, internalPrefix) {
324
+ suffix := strings.TrimPrefix(path, internalPrefix)
325
+ if _, shimmed := pkgIsShimmed(suffix); shimmed {
326
+ return r.has(suffix, o.Name())
327
+ }
328
+ return false // internal pkg we do not shim at all
329
+ }
330
+ return o.Exported() // external module type the plugin can import directly
331
+ default:
332
+ return false
333
+ }
334
+ }
335
+
336
+ func tupleReachable(t *types.Tuple, r reachable) bool {
337
+ if t == nil {
338
+ return true
339
+ }
340
+ for i := 0; i < t.Len(); i++ {
341
+ if !isReachable(t.At(i).Type(), r, map[types.Type]bool{}) {
342
+ return false
343
+ }
344
+ }
345
+ return true
346
+ }
347
+
348
+ func pkgIsShimmed(suffix string) (string, bool) {
349
+ for dir, s := range shimDirs {
350
+ if s == suffix {
351
+ return dir, true
352
+ }
353
+ }
354
+ return "", false
355
+ }
356
+
357
+ // commonPrefix returns the longest common string prefix of names ("" if the
358
+ // slice is empty or the names share no prefix).
359
+ func commonPrefix(names []string) string {
360
+ if len(names) == 0 {
361
+ return ""
362
+ }
363
+ p := names[0]
364
+ for _, n := range names[1:] {
365
+ for !strings.HasPrefix(n, p) {
366
+ p = p[:len(p)-1]
367
+ if p == "" {
368
+ return ""
369
+ }
370
+ }
371
+ }
372
+ return p
373
+ }
374
+
375
+ // attachUntypedConsts groups untyped exported consts into enum families by name.
376
+ // go/types reports a bit-reuse const like `ObjectFlagsContainsSpread = 1 << 22`
377
+ // as a plain int (no enum type), so its family must be recovered from the name.
378
+ // Each enum offers two candidate prefixes: its TYPE NAME, and — when distinct
379
+ // and >=3 chars — the common prefix of its already-typed members (the
380
+ // abbreviation case: OuterExpressionKinds' members are OEK*, including the
381
+ // untyped OEKExcludeJSDocTypeAssertion that carries no OuterExpressionKinds
382
+ // prefix). A const joins the family of its LONGEST matching prefix; a match must
383
+ // extend the prefix with an uppercase letter (a member-name boundary). Returns
384
+ // enum-type-name -> attached const names.
385
+ func attachUntypedConsts(enumNames []string, typedMembers map[string][]string, untyped []string) map[string][]string {
386
+ type cand struct{ prefix, enum string }
387
+ var cands []cand
388
+ for _, en := range enumNames {
389
+ cands = append(cands, cand{en, en})
390
+ if lcp := commonPrefix(typedMembers[en]); len(lcp) >= 3 &&
391
+ !strings.HasPrefix(lcp, en) && !strings.HasPrefix(en, lcp) {
392
+ cands = append(cands, cand{lcp, en})
393
+ }
394
+ }
395
+ sort.Slice(cands, func(i, j int) bool {
396
+ if len(cands[i].prefix) != len(cands[j].prefix) {
397
+ return len(cands[i].prefix) > len(cands[j].prefix)
398
+ }
399
+ if cands[i].prefix != cands[j].prefix {
400
+ return cands[i].prefix < cands[j].prefix
401
+ }
402
+ return cands[i].enum < cands[j].enum
403
+ })
404
+ out := map[string][]string{}
405
+ for _, cn := range untyped {
406
+ for _, c := range cands {
407
+ if len(cn) > len(c.prefix) && strings.HasPrefix(cn, c.prefix) &&
408
+ cn[len(c.prefix)] >= 'A' && cn[len(c.prefix)] <= 'Z' {
409
+ out[c.enum] = append(out[c.enum], cn)
410
+ break
411
+ }
412
+ }
413
+ }
414
+ return out
415
+ }
416
+
417
+ // Finding kinds.
418
+ type finding struct {
419
+ kind string // ENUM / FUNC / ESCAPE
420
+ pkg string
421
+ symbol string
422
+ detail string
423
+ }
424
+
425
+ func main() {
426
+ md := flag.Bool("md", false, "emit markdown report")
427
+ fix := flag.Bool("fix", false, "write enums_gen.go closing every exposed enum family (Layer 1)")
428
+ check := flag.Bool("check", false, "CI gate: exit non-zero on any TIER-1 enum gap or any TIER-2/3 gap outside the baseline")
429
+ writeBaseline := flag.Bool("write-baseline", false, "(re)generate the baseline of accepted TIER-2/3 gaps")
430
+ baselinePath := flag.String("baseline", "baseline.json", "path to the accepted-gap baseline")
431
+ anchor := flag.String("anchor", "./shim/ast", "module dir anchoring the typescript-go require")
432
+ shimRoot := flag.String("shim", "./shim", "shim root directory")
433
+ flag.Parse()
434
+
435
+ // A shim directory that is not registered in shimDirs would silently escape
436
+ // every check — close that hole before doing anything else.
437
+ if err := checkShimDirCoverage(*shimRoot); err != nil {
438
+ fmt.Fprintln(os.Stderr, "shim_audit:", err)
439
+ os.Exit(2)
440
+ }
441
+
442
+ r, err := scanShimReachable(*shimRoot)
443
+ if err != nil {
444
+ fmt.Fprintln(os.Stderr, "shim_audit:", err)
445
+ os.Exit(1)
446
+ }
447
+ inner, err := loadInner(*anchor)
448
+ if err != nil {
449
+ fmt.Fprintln(os.Stderr, "shim_audit:", err)
450
+ os.Exit(1)
451
+ }
452
+
453
+ findings, pool := analyze(r, inner)
454
+
455
+ switch {
456
+ case *fix:
457
+ runFix(findings, *shimRoot)
458
+ case *writeBaseline:
459
+ runWriteBaseline(findings, *baselinePath)
460
+ case *check:
461
+ runCheck(findings, *baselinePath)
462
+ default:
463
+ report(findings, pool, *md)
464
+ }
465
+ }
466
+
467
+ // analyze runs the three closure checks plus the unexported demand-pool scan and
468
+ // returns the deduped findings and pool.
469
+ func analyze(r reachable, inner map[string]*packages.Package) (findings, unexportedPool []finding) {
470
+ for suffix, pkg := range inner {
471
+ scope := pkg.Types.Scope()
472
+
473
+ // CHECK 1 (ENUM): consts of an exposed enum type that are not re-exported.
474
+ // Two passes so we also catch family members that upstream declares as
475
+ // UNTYPED ints (e.g. `ObjectFlagsContainsSpread = 1 << 22`, which reuses
476
+ // a bit position and drops the `ObjectFlags` annotation). go/types sees
477
+ // those as plain ints, so a strict type-based grouping silently misses
478
+ // them — a real blind spot the closure check must not have.
479
+ constsByType := map[string][]string{} // "pkg.Type" -> member const names
480
+ typeKey := map[string][2]string{} // "pkg.Type" -> {pkgSuffix, typeName}
481
+
482
+ // Seed a family for every exported named basic (int/string) type in this
483
+ // package, keyed by the TYPE — not by whether any of its consts happens to
484
+ // be typed. This is essential: an enum whose members are ALL untyped (every
485
+ // member `= iota` / `1<<n` with no annotation, e.g. printer's
486
+ // GeneratedIdentifierFlags) would otherwise never register as a family, and
487
+ // a partial re-export of exactly the #230 class would pass the gate.
488
+ var enumNames []string
489
+ for _, name := range scope.Names() {
490
+ tn, ok := scope.Lookup(name).(*types.TypeName)
491
+ if !ok || !tn.Exported() {
492
+ continue
493
+ }
494
+ // Underlying() resolves through both *types.Named and *types.Alias, so an
495
+ // enum declared as a basic type alias (`type PragmaKindFlags = uint8`) is
496
+ // seeded as a family just like a defined type — otherwise its untyped
497
+ // members would attach to nothing.
498
+ if _, isBasic := tn.Type().Underlying().(*types.Basic); isBasic {
499
+ enumNames = append(enumNames, name)
500
+ }
501
+ }
502
+
503
+ var basicConsts []string // exported consts with an unnamed basic type
504
+ for _, name := range scope.Names() {
505
+ c, ok := scope.Lookup(name).(*types.Const)
506
+ if !ok || !c.Exported() {
507
+ continue
508
+ }
509
+ if ps, tn, ok := namedInfo(c.Type()); ok {
510
+ key := ps + "." + tn
511
+ constsByType[key] = append(constsByType[key], name)
512
+ typeKey[key] = [2]string{ps, tn}
513
+ } else if _, isBasic := c.Type().Underlying().(*types.Basic); isBasic {
514
+ basicConsts = append(basicConsts, name)
515
+ }
516
+ }
517
+ // Attach each untyped const to its enum family by name. attachUntypedConsts
518
+ // matches the longest of each enum's prefixes — the type name, plus the
519
+ // abbreviation prefix shared by its typed members — so untyped+unprefixed
520
+ // members (e.g. OuterExpressionKinds' OEKExcludeJSDocTypeAssertion = 1<<6)
521
+ // are not silently dropped from the #230 enum-closure check.
522
+ typedMembers := make(map[string][]string, len(enumNames))
523
+ for _, en := range enumNames {
524
+ typedMembers[en] = constsByType[suffix+"."+en]
525
+ }
526
+ for en, consts := range attachUntypedConsts(enumNames, typedMembers, basicConsts) {
527
+ key := suffix + "." + en
528
+ constsByType[key] = append(constsByType[key], consts...)
529
+ typeKey[key] = [2]string{suffix, en}
530
+ }
531
+ for key, names := range constsByType {
532
+ tk := typeKey[key]
533
+ if !r.has(tk[0], tk[1]) {
534
+ continue // the enum type itself isn't exposed; not a closure gap
535
+ }
536
+ // Tier by partial exposure: an enum with SOME members already
537
+ // re-exported but not all is the near-certain #230 bug class
538
+ // (e.g. SignatureKindCall present, SignatureKindConstruct absent).
539
+ // An enum with zero members exposed is a deliberate type-only
540
+ // aliasing choice — report it, but at INFO.
541
+ var exposed, missing int
542
+ for _, n := range names {
543
+ if r.has(suffix, n) {
544
+ exposed++
545
+ } else {
546
+ missing++
547
+ }
548
+ }
549
+ kind := "ENUM"
550
+ detail := "member of exposed enum " + tk[1]
551
+ if exposed == 0 {
552
+ kind = "ENUM?"
553
+ detail += " (type-only: NO members exposed — likely intentional)"
554
+ } else {
555
+ detail += fmt.Sprintf(" (PARTIAL: %d/%d members exposed — missing siblings)", exposed, exposed+missing)
556
+ }
557
+ for _, n := range names {
558
+ if !r.has(suffix, n) {
559
+ findings = append(findings, finding{kind, suffix, n, detail})
560
+ }
561
+ }
562
+ }
563
+
564
+ // CHECK 2 (FUNC) + CHECK 3 (ESCAPE) over exported package-level funcs.
565
+ for _, name := range scope.Names() {
566
+ obj := scope.Lookup(name)
567
+ fn, ok := obj.(*types.Func)
568
+ if !ok || !fn.Exported() {
569
+ continue
570
+ }
571
+ sig := fn.Signature()
572
+ if sig.Recv() != nil || sig.TypeParams() != nil {
573
+ continue
574
+ }
575
+ if r.has(suffix, name) {
576
+ continue
577
+ }
578
+ // ESCAPE: an exported type in this func's signature that isn't aliased —
579
+ // a plugin could obtain/pass the value but cannot name its type. This must
580
+ // cover free functions, not just methods: `func GetFoo() *UnexposedType`
581
+ // is invisible to the FUNC check (its result isn't reachable) yet leaks an
582
+ // unnameable type.
583
+ for _, esc := range escapingTypes(sig, r) {
584
+ findings = append(findings, finding{"ESCAPE", esc[0], esc[1],
585
+ "appears in exported func " + name + " but is not aliased"})
586
+ }
587
+ // FUNC: every param/result already reachable — usable but not exposed.
588
+ if tupleReachable(sig.Params(), r) && tupleReachable(sig.Results(), r) {
589
+ findings = append(findings, finding{"FUNC", suffix, name,
590
+ "all params/results already reachable — usable but not exposed"})
591
+ }
592
+ }
593
+
594
+ // CHECK 3 (ESCAPE) + unexported pool, over the FULL method set of each
595
+ // EXPOSED named type — including methods promoted from embedded types, which
596
+ // named.Method(i) would miss (e.g. printer.NodeFactory embeds ast.NodeFactory).
597
+ for _, name := range scope.Names() {
598
+ tn, ok := scope.Lookup(name).(*types.TypeName)
599
+ if !ok || !r.has(suffix, name) {
600
+ continue // only types the shim already exposes
601
+ }
602
+ named, ok := tn.Type().(*types.Named)
603
+ if !ok {
604
+ continue
605
+ }
606
+ mset := types.NewMethodSet(types.NewPointer(named))
607
+ for i := 0; i < mset.Len(); i++ {
608
+ m, ok := mset.At(i).Obj().(*types.Func)
609
+ if !ok {
610
+ continue
611
+ }
612
+ if !m.Exported() {
613
+ if isUsefulUnexported(m, r) {
614
+ unexportedPool = append(unexportedPool, finding{"UNEXPORTED", suffix,
615
+ name + "." + m.Name(), sigString(m.Signature())})
616
+ }
617
+ continue
618
+ }
619
+ // Exported methods of an exposed type are directly callable;
620
+ // the gap is any escaping result/param type that isn't exposed.
621
+ for _, esc := range escapingTypes(m.Signature(), r) {
622
+ findings = append(findings, finding{"ESCAPE", esc[0], esc[1],
623
+ "appears in exposed " + name + "." + m.Name() + " but is not aliased"})
624
+ }
625
+ }
626
+ }
627
+ }
628
+
629
+ return dedupe(findings), dedupe(unexportedPool)
630
+ }
631
+
632
+ // escapingTypes returns [pkg,name] of exported internal named types in a
633
+ // signature that are not reachable through the shim.
634
+ func escapingTypes(sig *types.Signature, r reachable) [][2]string {
635
+ var out [][2]string
636
+ collect := func(t *types.Tuple) {
637
+ if t == nil {
638
+ return
639
+ }
640
+ for i := 0; i < t.Len(); i++ {
641
+ walkNamed(t.At(i).Type(), func(ps, tn string, exported bool) {
642
+ if exported && !r.has(ps, tn) {
643
+ if _, ok := pkgIsShimmed(ps); ok {
644
+ out = append(out, [2]string{ps, tn})
645
+ }
646
+ }
647
+ })
648
+ }
649
+ }
650
+ collect(sig.Params())
651
+ collect(sig.Results())
652
+ return out
653
+ }
654
+
655
+ // walkNamed visits the internal named types syntactically reachable from t and
656
+ // calls fn for each, descending pointers, slices, arrays, maps and channels —
657
+ // the shapes tsgo's exported signatures actually use. By design (TIER-3 escape
658
+ // detection is best-effort, ratcheted by baseline.json — not the airtight enum
659
+ // layer) it does NOT descend into struct fields, interface/func element types,
660
+ // or a generic Named's type arguments; an escaping type reachable only through
661
+ // one of those is left to the baseline.
662
+ func walkNamed(t types.Type, fn func(ps, tn string, exported bool)) {
663
+ switch x := t.(type) {
664
+ case *types.Pointer:
665
+ walkNamed(x.Elem(), fn)
666
+ case *types.Slice:
667
+ walkNamed(x.Elem(), fn)
668
+ case *types.Array:
669
+ walkNamed(x.Elem(), fn)
670
+ case *types.Map:
671
+ walkNamed(x.Key(), fn)
672
+ walkNamed(x.Elem(), fn)
673
+ case *types.Chan:
674
+ walkNamed(x.Elem(), fn)
675
+ case *types.Named:
676
+ if ps, tn, ok := namedInfo(x); ok {
677
+ fn(ps, tn, x.Obj().Exported())
678
+ }
679
+ }
680
+ }
681
+
682
+ // isUsefulUnexported keeps unexported methods whose params/results are all
683
+ // reachable — i.e. ones a plugin could actually call if linknamed.
684
+ func isUsefulUnexported(m *types.Func, r reachable) bool {
685
+ sig := m.Signature()
686
+ if sig.TypeParams() != nil {
687
+ return false
688
+ }
689
+ return tupleReachable(sig.Params(), r) && tupleReachable(sig.Results(), r)
690
+ }
691
+
692
+ func sigString(sig *types.Signature) string {
693
+ return strings.ReplaceAll(types.TypeString(sig, func(p *types.Package) string {
694
+ return p.Name()
695
+ }), "func", "")
696
+ }
697
+
698
+ func dedupe(in []finding) []finding {
699
+ // Keep the lexicographically smallest detail per key so the report text is
700
+ // deterministic when a type is reached through more than one path (map
701
+ // iteration order would otherwise flap the human-readable detail). The
702
+ // gate/baseline key is kind|pkg|symbol only, so this never affects pass/fail.
703
+ idx := map[string]int{}
704
+ var out []finding
705
+ for _, f := range in {
706
+ k := f.kind + "|" + f.pkg + "|" + f.symbol
707
+ if i, ok := idx[k]; ok {
708
+ if f.detail < out[i].detail {
709
+ out[i].detail = f.detail
710
+ }
711
+ continue
712
+ }
713
+ idx[k] = len(out)
714
+ out = append(out, f)
715
+ }
716
+ sort.Slice(out, func(i, j int) bool {
717
+ if out[i].pkg != out[j].pkg {
718
+ return out[i].pkg < out[j].pkg
719
+ }
720
+ if out[i].kind != out[j].kind {
721
+ return out[i].kind < out[j].kind
722
+ }
723
+ return out[i].symbol < out[j].symbol
724
+ })
725
+ return out
726
+ }
727
+
728
+ // tierOf maps a finding kind to a confidence tier. Tier 1 is the near-certain
729
+ // bug class (#230); higher tiers are progressively noisier candidate pools.
730
+ func tierOf(kind string) int {
731
+ switch kind {
732
+ case "ENUM": // partial enum — some members exposed, siblings missing
733
+ return 1
734
+ case "FUNC": // exported op over already-reachable types
735
+ return 2
736
+ case "ESCAPE": // type returned by an exposed op but not aliased
737
+ return 3
738
+ default: // ENUM? type-only enums
739
+ return 4
740
+ }
741
+ }
742
+
743
+ func report(findings, pool []finding, md bool) {
744
+ tiers := map[int][]finding{}
745
+ for _, f := range findings {
746
+ t := tierOf(f.kind)
747
+ tiers[t] = append(tiers[t], f)
748
+ }
749
+ h := func(s string) {
750
+ if md {
751
+ fmt.Printf("\n## %s\n\n", s)
752
+ } else {
753
+ fmt.Printf("\n=== %s ===\n", s)
754
+ }
755
+ }
756
+ row := func(f finding) {
757
+ if md {
758
+ fmt.Printf("| %s | %s | `%s` | %s |\n", f.kind, f.pkg, f.symbol, f.detail)
759
+ } else {
760
+ fmt.Printf("[%-5s] %s.%s — %s\n", f.kind, f.pkg, f.symbol, f.detail)
761
+ }
762
+ }
763
+ thead := func() {
764
+ if md {
765
+ fmt.Printf("| Kind | Pkg | Symbol | Detail |\n|------|-----|--------|--------|\n")
766
+ }
767
+ }
768
+
769
+ if md {
770
+ fmt.Printf("# Shim closure audit\n\n")
771
+ } else {
772
+ fmt.Printf("=== Shim closure audit ===\n")
773
+ }
774
+ fmt.Printf("Tier 1 PARTIAL-ENUM (near-certain bugs): %d | Tier 2 FUNC-over-exposed: %d | Tier 3 ESCAPE: %d | Tier 4 type-only enums: %d | Unexported demand pool: %d\n",
775
+ len(tiers[1]), len(tiers[2]), len(tiers[3]), len(tiers[4]), len(pool))
776
+
777
+ h("TIER 1 — partial enums (a sibling const is missing; this is the #230 class)")
778
+ thead()
779
+ for _, f := range tiers[1] {
780
+ row(f)
781
+ }
782
+
783
+ h("TIER 2 — exported funcs whose params/results are all already reachable")
784
+ thead()
785
+ for _, f := range tiers[2] {
786
+ row(f)
787
+ }
788
+
789
+ h("TIER 3 — escaping types (produced by an exposed op but not aliased)")
790
+ thead()
791
+ for _, f := range tiers[3] {
792
+ row(f)
793
+ }
794
+
795
+ // Tier 4 (type-only enums) and the unexported pool are large/low-signal:
796
+ // summarize by package rather than dumping every line.
797
+ h("TIER 4 — type-only enums (no members exposed; likely intentional) — counts by pkg")
798
+ fmt.Print(countByPkg(tiers[4]))
799
+
800
+ h("UNEXPORTED demand pool (closure-invisible — needs consumer/agent signal) — counts by pkg")
801
+ fmt.Print(countByPkg(pool))
802
+ if !md {
803
+ fmt.Printf("(re-run with a grep, e.g. `| grep '\\.getMin'`, to inspect specific candidates)\n")
804
+ }
805
+ }
806
+
807
+ func countByPkg(in []finding) string {
808
+ c := map[string]int{}
809
+ for _, f := range in {
810
+ c[f.pkg]++
811
+ }
812
+ keys := make([]string, 0, len(c))
813
+ for k := range c {
814
+ keys = append(keys, k)
815
+ }
816
+ sort.Strings(keys)
817
+ var b strings.Builder
818
+ for _, k := range keys {
819
+ fmt.Fprintf(&b, " %-16s %d\n", k, c[k])
820
+ }
821
+ return b.String()
822
+ }
823
+
824
+ // findingKey is the stable identity of a gap, used for baseline membership.
825
+ func findingKey(f finding) string { return f.kind + "|" + f.pkg + "|" + f.symbol }
826
+
827
+ // baselineFile is the on-disk schema of accepted TIER-2/3 gaps. TIER-1 enum
828
+ // gaps are never baselined — they are zero-tolerance and fixed by -fix.
829
+ type baselineFile struct {
830
+ Note string `json:"note"`
831
+ Accepted []string `json:"accepted"`
832
+ }
833
+
834
+ // shimPackageName reads the `package` clause from a .go file in dir.
835
+ func shimPackageName(dir string) string {
836
+ paths, _ := filepath.Glob(filepath.Join(dir, "*.go"))
837
+ fset := token.NewFileSet()
838
+ for _, p := range paths {
839
+ if f, err := parser.ParseFile(fset, p, nil, parser.PackageClauseOnly); err == nil {
840
+ return f.Name.Name
841
+ }
842
+ }
843
+ return filepath.Base(dir)
844
+ }
845
+
846
+ // runFix writes shim/<pkg>/enums_gen.go for every package carrying TIER-1 enum
847
+ // gaps, re-exporting each missing member so the family is complete. Const
848
+ // re-exports carry no behavior and no ABI risk, so closing the whole family is
849
+ // always safe — and makes the #230 class structurally impossible.
850
+ //
851
+ // A member REMOVAL across a typescript-go bump is the one case -fix does not
852
+ // auto-heal: a package whose last gap disappears is not rewritten here, so a
853
+ // stale enums_gen.go still referencing the removed symbol must be deleted by
854
+ // hand (the native build catches the dangling reference).
855
+ func runFix(findings []finding, shimRoot string) {
856
+ byPkg := map[string][]string{}
857
+ for _, f := range findings {
858
+ if f.kind == "ENUM" {
859
+ byPkg[f.pkg] = append(byPkg[f.pkg], f.symbol)
860
+ }
861
+ }
862
+ if len(byPkg) == 0 {
863
+ fmt.Println("shim_audit: no enum-family gaps; nothing to fix")
864
+ return
865
+ }
866
+ pkgs := make([]string, 0, len(byPkg))
867
+ for p := range byPkg {
868
+ pkgs = append(pkgs, p)
869
+ }
870
+ sort.Strings(pkgs)
871
+ for _, pkg := range pkgs {
872
+ names := byPkg[pkg]
873
+ sort.Strings(names)
874
+ dir := filepath.Join(shimRoot, pkg)
875
+ pkgName := shimPackageName(dir)
876
+ alias := "inner" + pkgName
877
+ var b strings.Builder
878
+ b.WriteString("// Code generated by packages/ttsc/tools/shim_audit -fix. DO NOT EDIT.\n//\n")
879
+ b.WriteString("// Completes every exposed enum family: re-exports each member not already\n")
880
+ b.WriteString("// re-exported elsewhere in the shim package, so a plugin that can name the\n")
881
+ b.WriteString("// enum type can name all of its values. Prevents the #230 class (a sibling\n")
882
+ b.WriteString("// const silently missing). Regenerate after a typescript-go bump with\n")
883
+ b.WriteString("// `pnpm --filter ttsc shim:audit -fix`.\n\n")
884
+ fmt.Fprintf(&b, "package %s\n\n", pkgName)
885
+ fmt.Fprintf(&b, "import %s %q\n\n", alias, internalPrefix+pkg)
886
+ b.WriteString("const (\n")
887
+ for _, n := range names {
888
+ fmt.Fprintf(&b, "\t%s = %s.%s\n", n, alias, n)
889
+ }
890
+ b.WriteString(")\n")
891
+ out := filepath.Join(dir, "enums_gen.go")
892
+ formatted, err := format.Source([]byte(b.String()))
893
+ if err != nil {
894
+ fmt.Fprintf(os.Stderr, "shim_audit: format %s: %v\n", out, err)
895
+ formatted = []byte(b.String())
896
+ }
897
+ // Match the repo's gofmt-2spaces convention (see .vscode/gofmt-2spaces.sh)
898
+ // so a later `pnpm format` does not churn these generated files.
899
+ formatted = bytes.ReplaceAll(formatted, []byte("\t"), []byte(" "))
900
+ if err := os.WriteFile(out, formatted, 0o644); err != nil {
901
+ fmt.Fprintln(os.Stderr, "shim_audit:", err)
902
+ os.Exit(1)
903
+ }
904
+ fmt.Printf("shim_audit: wrote %s (%d members)\n", out, len(names))
905
+ }
906
+ }
907
+
908
+ // runWriteBaseline records the current TIER-2/3 gaps as accepted, so the gate
909
+ // fails only on NEW ones. The list can only shrink as gaps get exposed.
910
+ func runWriteBaseline(findings []finding, path string) {
911
+ var keys []string
912
+ for _, f := range findings {
913
+ if f.kind == "FUNC" || f.kind == "ESCAPE" {
914
+ keys = append(keys, findingKey(f))
915
+ }
916
+ }
917
+ sort.Strings(keys)
918
+ data, err := json.MarshalIndent(baselineFile{
919
+ Note: "Accepted TIER-2 (reachable funcs) and TIER-3 (escaping types) shim gaps. The gate fails on any gap NOT listed here; expose a symbol and remove its line, or run -write-baseline to accept new ones deliberately. TIER-1 enum gaps are never listed — they are zero-tolerance (run -fix).",
920
+ Accepted: keys,
921
+ }, "", " ")
922
+ if err != nil {
923
+ fmt.Fprintln(os.Stderr, "shim_audit:", err)
924
+ os.Exit(1)
925
+ }
926
+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
927
+ fmt.Fprintln(os.Stderr, "shim_audit:", err)
928
+ os.Exit(1)
929
+ }
930
+ fmt.Printf("shim_audit: wrote %s (%d accepted gaps)\n", path, len(keys))
931
+ }
932
+
933
+ // runCheck is the CI gate. It fails on any TIER-1 enum gap (zero tolerance) or
934
+ // any TIER-2/3 gap not present in the baseline.
935
+ func runCheck(findings []finding, path string) {
936
+ accepted := map[string]bool{}
937
+ if data, err := os.ReadFile(path); err == nil {
938
+ var bf baselineFile
939
+ if err := json.Unmarshal(data, &bf); err != nil {
940
+ fmt.Fprintf(os.Stderr, "shim_audit: parse %s: %v\n", path, err)
941
+ os.Exit(2)
942
+ }
943
+ for _, k := range bf.Accepted {
944
+ accepted[k] = true
945
+ }
946
+ } else {
947
+ fmt.Fprintf(os.Stderr, "shim_audit: WARNING no baseline at %s; treating all func/type gaps as new\n", path)
948
+ }
949
+
950
+ var enumGaps, newGaps []finding
951
+ live := map[string]bool{}
952
+ for _, f := range findings {
953
+ switch f.kind {
954
+ case "ENUM":
955
+ enumGaps = append(enumGaps, f)
956
+ case "FUNC", "ESCAPE":
957
+ live[findingKey(f)] = true
958
+ if !accepted[findingKey(f)] {
959
+ newGaps = append(newGaps, f)
960
+ }
961
+ }
962
+ }
963
+ // Non-failing hygiene note: baseline lines that no longer match a gap (the
964
+ // symbol got exposed) are dead weight; the ratchet stays safe but suggest a prune.
965
+ var stale int
966
+ for k := range accepted {
967
+ if !live[k] {
968
+ stale++
969
+ }
970
+ }
971
+ if stale > 0 {
972
+ fmt.Fprintf(os.Stderr, "shim_audit: note: %d baseline entr(y/ies) no longer match a gap; prune with -write-baseline\n", stale)
973
+ }
974
+
975
+ if len(enumGaps) == 0 && len(newGaps) == 0 {
976
+ fmt.Println("shim_audit: OK — every exposed enum family is complete and no new reachable gaps")
977
+ return
978
+ }
979
+ if len(enumGaps) > 0 {
980
+ fmt.Fprintf(os.Stderr, "\nshim_audit: FAIL — %d enum-family member(s) of an EXPOSED enum are not re-exported.\n", len(enumGaps))
981
+ fmt.Fprintf(os.Stderr, " This is the #230 class. Fix mechanically: `pnpm --filter ttsc shim:audit -fix`.\n")
982
+ for _, f := range enumGaps {
983
+ fmt.Fprintf(os.Stderr, " ENUM %s.%s\n", f.pkg, f.symbol)
984
+ }
985
+ }
986
+ if len(newGaps) > 0 {
987
+ fmt.Fprintf(os.Stderr, "\nshim_audit: FAIL — %d new reachable gap(s) not in the baseline.\n", len(newGaps))
988
+ fmt.Fprintf(os.Stderr, " Expose them in the shim, or accept deliberately: `pnpm --filter ttsc shim:audit -write-baseline`.\n")
989
+ for _, f := range newGaps {
990
+ fmt.Fprintf(os.Stderr, " %-6s %s.%s — %s\n", f.kind, f.pkg, f.symbol, f.detail)
991
+ }
992
+ }
993
+ fmt.Fprintln(os.Stderr)
994
+ os.Exit(1)
995
+ }