ttsc 0.22.0 → 0.24.0

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 (91) hide show
  1. package/cmd/ttscgraph/serve.go +264 -48
  2. package/cmd/ttscgraph/serve_early_error_encode_failure_is_observable_test.go +40 -0
  3. package/cmd/ttscgraph/serve_graph_snapshot_version_matches_typescript_client_test.go +32 -0
  4. package/cmd/ttscgraph/serve_phase_trace_accounts_for_load_failure_test.go +45 -0
  5. package/cmd/ttscgraph/serve_phase_trace_is_opt_in_and_payload_free_test.go +52 -0
  6. package/cmd/ttscgraph/serve_protocol_negotiates_incremental_shards_test.go +44 -0
  7. package/cmd/ttscgraph/serve_session_reloads_commonjs_package_entry_for_esm_test.go +2 -2
  8. package/cmd/ttscgraph/serve_session_reloads_superseding_module_candidates_test.go +46 -20
  9. package/cmd/ttscgraph/serve_session_retries_failed_dump_test.go +9 -9
  10. package/cmd/ttscgraph/serve_shards.go +1390 -0
  11. package/cmd/ttscgraph/serve_shards_abandoned_generation_converges_on_retry_test.go +92 -0
  12. package/cmd/ttscgraph/serve_shards_delete_removed_source_test.go +57 -0
  13. package/cmd/ttscgraph/serve_shards_external_endpoints_test.go +53 -0
  14. package/cmd/ttscgraph/serve_shards_keep_source_distributed_dependencies_at_the_boundary_test.go +108 -0
  15. package/cmd/ttscgraph/serve_shards_match_full_dump_oracle_test.go +88 -0
  16. package/cmd/ttscgraph/serve_shards_merge_pending_and_new_edits_test.go +50 -0
  17. package/cmd/ttscgraph/serve_shards_prune_unreferenced_external_endpoints_test.go +65 -0
  18. package/cmd/ttscgraph/serve_shards_publish_created_source_test.go +92 -0
  19. package/cmd/ttscgraph/serve_shards_rebuild_declaration_edit_test.go +43 -0
  20. package/cmd/ttscgraph/serve_shards_refresh_cross_file_implementation_test.go +63 -0
  21. package/cmd/ttscgraph/serve_shards_refresh_dependent_diagnostics_test.go +80 -0
  22. package/cmd/ttscgraph/serve_shards_rename_moves_source_identity_test.go +95 -0
  23. package/cmd/ttscgraph/serve_shards_retry_preserves_committed_generation_test.go +48 -0
  24. package/cmd/ttscgraph/serve_shards_reuse_unaffected_sources_test.go +121 -0
  25. package/driver/declaration_shape.go +85 -0
  26. package/driver/program.go +39 -10
  27. package/go.mod +1 -1
  28. package/internal/graph/build.go +62 -10
  29. package/internal/graph/build_files_reuses_committed_cross_file_endpoints_test.go +87 -0
  30. package/internal/graph/build_keeps_source_distributed_dependencies_at_the_external_boundary_test.go +74 -0
  31. package/internal/graph/dump.go +84 -43
  32. package/internal/graph/dump_path_mapper_caches_alias_resolution_test.go +29 -0
  33. package/internal/graph/dump_paths.go +134 -11
  34. package/internal/graph/dump_paths_canonicalize_windows_short_root_test.go +39 -0
  35. package/internal/graph/dump_rejects_provenance_only_path_errors_test.go +32 -0
  36. package/internal/graph/dump_serializes_the_full_graph_test.go +2 -2
  37. package/internal/graph/dump_symlink_project_publishes_matching_base_test.go +82 -0
  38. package/internal/graph/edges.go +58 -16
  39. package/internal/graph/exports.go +9 -2
  40. package/internal/graph/graph.go +47 -7
  41. package/internal/graph/graph_kind_contracts_match_their_producers_test.go +121 -0
  42. package/internal/graph/node_modifiers_emit_union_strings_test.go +1 -1
  43. package/internal/graph/provenance.go +11 -1
  44. package/internal/graph/resolve.go +1 -4
  45. package/internal/graph/source.go +23 -0
  46. package/internal/graph/wire_node_ids_share_alias_resolution_test.go +29 -0
  47. package/internal/lspserver/lsp_launcher_reload_directory_fingerprint_parity_windows_test.go +94 -0
  48. package/internal/lspserver/lsp_project_input_glob_path_other.go +16 -0
  49. package/internal/lspserver/lsp_project_input_glob_path_windows.go +36 -0
  50. package/internal/lspserver/lsp_project_input_path_key_other.go +17 -0
  51. package/internal/lspserver/lsp_project_input_path_key_windows.go +228 -0
  52. package/internal/lspserver/lsp_project_input_path_key_windows_test.go +460 -0
  53. package/internal/lspserver/lsp_project_inputs.go +117 -49
  54. package/internal/lspserver/lsp_reload_directories_separate_glob_territory_test.go +181 -0
  55. package/lib/flags/schema.js +1 -1
  56. package/lib/flags/schema.js.map +1 -1
  57. package/lib/internal/projectInputPathIdentity.d.ts +13 -5
  58. package/lib/internal/projectInputPathIdentity.js +95 -41
  59. package/lib/internal/projectInputPathIdentity.js.map +1 -1
  60. package/lib/internal/windowsDirectoryCaseSensitivity.d.ts +10 -0
  61. package/lib/internal/windowsDirectoryCaseSensitivity.js +34 -0
  62. package/lib/internal/windowsDirectoryCaseSensitivity.js.map +1 -0
  63. package/lib/launcher/internal/runTtsc.js +5 -0
  64. package/lib/launcher/internal/runTtsc.js.map +1 -1
  65. package/lib/launcher/internal/runTtscserver.js +59 -8
  66. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  67. package/lib/launcher/internal/runTtsx.js +61 -7
  68. package/lib/launcher/internal/runTtsx.js.map +1 -1
  69. package/lib/launcher/internal/runtimeHooks.d.ts +6 -5
  70. package/lib/launcher/internal/runtimeHooks.js +7 -15
  71. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  72. package/lib/launcher/internal/watchTopology.d.ts +82 -8
  73. package/lib/launcher/internal/watchTopology.js +535 -187
  74. package/lib/launcher/internal/watchTopology.js.map +1 -1
  75. package/lib/plugin/internal/descriptorProcessFailure.d.ts +32 -0
  76. package/lib/plugin/internal/descriptorProcessFailure.js +73 -0
  77. package/lib/plugin/internal/descriptorProcessFailure.js.map +1 -0
  78. package/lib/plugin/internal/loadProjectPlugins.js +29 -8
  79. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  80. package/package.json +12 -8
  81. package/src/flags/schema.ts +2 -1
  82. package/src/internal/projectInputPathIdentity.ts +150 -46
  83. package/src/internal/windowsDirectoryCaseSensitivity.ts +33 -0
  84. package/src/launcher/internal/runTtsc.ts +7 -0
  85. package/src/launcher/internal/runTtscserver.ts +81 -11
  86. package/src/launcher/internal/runTtsx.ts +87 -10
  87. package/src/launcher/internal/runtimeHooks.ts +16 -16
  88. package/src/launcher/internal/watchTopology.ts +679 -208
  89. package/src/plugin/internal/descriptorProcessFailure.ts +100 -0
  90. package/src/plugin/internal/loadProjectPlugins.ts +41 -8
  91. package/tools/shim_audit/baseline.json +0 -9
@@ -0,0 +1,87 @@
1
+ package graph
2
+
3
+ import (
4
+ "path/filepath"
5
+ "testing"
6
+
7
+ "github.com/samchon/ttsc/packages/ttsc/driver"
8
+ )
9
+
10
+ // TestBuildFilesReusesCommittedCrossFileEndpoints verifies partial extraction
11
+ // resolves selected-file facts against, but does not re-emit, the preceding
12
+ // generation's unchanged declaration index.
13
+ //
14
+ // A member implementation edge is the boundary case: resolving the base class
15
+ // alone is insufficient because the edge targets the base member node. If a
16
+ // partial builder sees only its replacement file, that member disappears unless
17
+ // the committed endpoint index participates in checker resolution. Conversely,
18
+ // emitting the base and unrelated nodes again would turn a file edit back into
19
+ // a whole-project graph replacement.
20
+ //
21
+ // 1. Compile a base interface, one implementation and an unrelated source.
22
+ // 2. Build the complete generation, then rebuild only the implementation file
23
+ // against its node index.
24
+ // 3. Require the implementation/member facts and cross-file edge while
25
+ // rejecting re-emission of the base and unrelated nodes.
26
+ func TestBuildFilesReusesCommittedCrossFileEndpoints(t *testing.T) {
27
+ root := t.TempDir()
28
+ writeFile(t, filepath.Join(root, "tsconfig.json"), `{
29
+ "compilerOptions": {
30
+ "target": "ES2022",
31
+ "module": "commonjs",
32
+ "strict": true,
33
+ "rootDir": "src",
34
+ "outDir": "dist"
35
+ },
36
+ "include": ["src"]
37
+ }
38
+ `)
39
+ writeFile(t, filepath.Join(root, "src", "base.ts"), `export interface Base {
40
+ run(): void;
41
+ }
42
+ `)
43
+ writeFile(t, filepath.Join(root, "src", "impl.ts"), `import { Base } from "./base";
44
+ export class Impl implements Base {
45
+ run(): void {}
46
+ }
47
+ `)
48
+ writeFile(t, filepath.Join(root, "src", "unrelated.ts"), `export const unrelated = 1;
49
+ `)
50
+
51
+ prog, diags, err := driver.LoadProgram(root, "tsconfig.json", driver.LoadProgramOptions{})
52
+ if err != nil {
53
+ t.Fatal(err)
54
+ }
55
+ if len(diags) != 0 {
56
+ t.Fatalf("unexpected diagnostics: %v", diags)
57
+ }
58
+ defer func() { _ = prog.Close() }()
59
+
60
+ complete := Build(prog)
61
+ implFile := sourceFile(t, prog, "impl.ts").FileName()
62
+ baseFile := sourceFile(t, prog, "base.ts").FileName()
63
+ unrelatedFile := sourceFile(t, prog, "unrelated.ts").FileName()
64
+ partial := BuildFiles(prog, []string{implFile}, complete.Nodes)
65
+
66
+ implMethod := nodeID(implFile, "Impl.run", NodeMethod)
67
+ baseMethod := nodeID(baseFile, "Base.run", NodeMethod)
68
+ if partial.Nodes[implMethod] == nil {
69
+ t.Fatalf("partial build omitted selected method %s", implMethod)
70
+ }
71
+ for _, node := range partial.Nodes {
72
+ if node.File == baseFile || node.File == unrelatedFile {
73
+ t.Fatalf("partial build re-emitted unchanged node %s", node.ID)
74
+ }
75
+ }
76
+ found := false
77
+ for _, edge := range partial.Edges {
78
+ if edge.From == implMethod && edge.To == baseMethod &&
79
+ edge.Kind == EdgeMemberRelation && edge.Origin == "implements" {
80
+ found = true
81
+ break
82
+ }
83
+ }
84
+ if !found {
85
+ t.Fatalf("partial build omitted checker-valid member edge %s -> %s", implMethod, baseMethod)
86
+ }
87
+ }
@@ -0,0 +1,74 @@
1
+ package graph
2
+
3
+ import (
4
+ "path/filepath"
5
+ "strings"
6
+ "testing"
7
+
8
+ "github.com/samchon/ttsc/packages/ttsc/driver"
9
+ )
10
+
11
+ // TestBuildKeepsSourceDistributedDependenciesAtTheExternalBoundary proves a
12
+ // package whose public entry is raw TypeScript remains a referenced leaf rather
13
+ // than becoming authored graph content. The resident source text is retained as
14
+ // provenance evidence even though its declarations are not walked.
15
+ //
16
+ // 1. Load one workspace source that imports a raw TypeScript package entry.
17
+ // 2. Assert only the referenced dependency symbol becomes an external leaf.
18
+ // 3. Assert provenance retains the dependency source text without its facts.
19
+ func TestBuildKeepsSourceDistributedDependenciesAtTheExternalBoundary(t *testing.T) {
20
+ root := t.TempDir()
21
+ writeFile(t, filepath.Join(root, "tsconfig.json"), `{
22
+ "compilerOptions": { "target": "ES2022", "module": "commonjs", "strict": true },
23
+ "files": ["src/main.ts"]
24
+ }
25
+ `)
26
+ writeFile(t, filepath.Join(root, "node_modules", "dep-src", "package.json"), `{
27
+ "name": "dep-src",
28
+ "version": "1.0.0",
29
+ "main": "src/index.ts"
30
+ }
31
+ `)
32
+ writeFile(t, filepath.Join(root, "node_modules", "dep-src", "src", "index.ts"), `export function dependencyValue(): number { return 1; }
33
+ export function dependencyInternal(): number { return dependencyValue(); }
34
+ `)
35
+ writeFile(t, filepath.Join(root, "src", "main.ts"), `import { dependencyValue } from "dep-src";
36
+ export function workspaceValue(): number { return dependencyValue() + 1; }
37
+ `)
38
+
39
+ program, diagnostics, err := driver.LoadProgram(root, "tsconfig.json", driver.LoadProgramOptions{})
40
+ if err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ if len(diagnostics) != 0 {
44
+ t.Fatalf("unexpected diagnostics: %v", diagnostics)
45
+ }
46
+ defer func() { _ = program.Close() }()
47
+
48
+ built := Build(program)
49
+ dependency := findNodeByName(built, "dependencyValue")
50
+ if dependency == nil || !dependency.External || !strings.Contains(filepath.ToSlash(dependency.File), "/node_modules/dep-src/") {
51
+ t.Fatalf("dependencyValue should be one external boundary leaf: %+v", dependency)
52
+ }
53
+ if internal := findNodeByName(built, "dependencyInternal"); internal != nil {
54
+ t.Fatalf("unreferenced dependency implementation leaked into the authored graph: %+v", internal)
55
+ }
56
+ workspace := findNodeByName(built, "workspaceValue")
57
+ if workspace == nil || workspace.External {
58
+ t.Fatalf("workspaceValue should remain authored source: %+v", workspace)
59
+ }
60
+ if !hasEdge(built, workspace.ID, dependency.ID, EdgeValueCall) {
61
+ t.Fatalf("missing workspaceValue -> dependencyValue boundary call: %v", built.Edges)
62
+ }
63
+
64
+ dependencyTextRetained := false
65
+ for file := range SourceTexts(program) {
66
+ if strings.Contains(filepath.ToSlash(file), "/node_modules/dep-src/") {
67
+ dependencyTextRetained = true
68
+ break
69
+ }
70
+ }
71
+ if !dependencyTextRetained {
72
+ t.Fatal("resident dependency source was removed from provenance text")
73
+ }
74
+ }
@@ -30,7 +30,8 @@ import (
30
30
  // expression. It is display/expansion only, never identity.
31
31
  //
32
32
  // File is omitted when the reader reconstructs it exactly: a node's span is in
33
- // the node's file, and an edge's span is in the file its `from` id names. The
33
+ // the node's file, and an ordinary edge's span is in the file its `from` id
34
+ // names. Cross-file assigned implementations keep the actual evidence file. The
34
35
  // path is long, it rode the wire once per node and once per edge, and on VS Code
35
36
  // those two copies are 55 MB of a 323 MB document that then has to be encoded,
36
37
  // piped, parsed and validated. An `implementation` span keeps its file — that one
@@ -131,6 +132,14 @@ type Dump struct {
131
132
  Edges []DumpEdge `json:"edges"`
132
133
  }
133
134
 
135
+ // DumpFacts is the path-normalized fact payload of one graph shard. It omits
136
+ // project-wide provenance and diagnostics so an incremental producer can
137
+ // project one invalidated closure without reconstructing a complete Dump.
138
+ type DumpFacts struct {
139
+ Nodes []DumpNode `json:"nodes"`
140
+ Edges []DumpEdge `json:"edges"`
141
+ }
142
+
134
143
  // DumpOrigin is the snapshot evidence a caller attaches to a dump: who built it
135
144
  // and what the same program generation had to say about the code. It is a
136
145
  // separate struct because only the commands that own a compiler session can
@@ -153,6 +162,61 @@ type DumpOrigin struct {
153
162
  // error before serialization when a path is on another filesystem root or two
154
163
  // physical sources would collide at one wire identity.
155
164
  func NewDump(g *Graph, project, tsconfig string, ignored map[string]bool, sources map[string]string, origin DumpOrigin) (Dump, error) {
165
+ facts, ctx, err := newDumpFacts(g, project, ignored, sources)
166
+ if err != nil {
167
+ return Dump{}, err
168
+ }
169
+
170
+ // The schema version describes this code, not the caller's belief about it,
171
+ // so stamp it here rather than trusting what came in.
172
+ provenance := ctx.provenance(origin.Provenance)
173
+ provenance.SchemaVersion = DumpSchemaVersion
174
+
175
+ // A nil slice encodes as JSON null, and null is not an empty list: a reader
176
+ // validating `string[]` rejects it, and a reader that does not would have to
177
+ // guess which of the two the producer meant. Every list on the wire is a list.
178
+ if provenance.Capabilities == nil {
179
+ provenance.Capabilities = []string{}
180
+ }
181
+ if provenance.Sources == nil {
182
+ provenance.Sources = []SourceDigest{}
183
+ }
184
+ if provenance.Universe.Configs == nil {
185
+ provenance.Universe.Configs = []FileDigest{}
186
+ }
187
+ if provenance.Universe.Roots == nil {
188
+ provenance.Universe.Roots = []RootFile{}
189
+ }
190
+
191
+ diagnostics := ctx.diagnostics(origin.Diagnostics)
192
+ if diagnostics == nil {
193
+ diagnostics = []Diagnostic{}
194
+ }
195
+ target := ctx.rel(tsconfig)
196
+ if err := ctx.pathError(); err != nil {
197
+ return Dump{}, err
198
+ }
199
+
200
+ return Dump{
201
+ Project: ctx.paths.project,
202
+ Tsconfig: target,
203
+ Provenance: provenance,
204
+ Diagnostics: diagnostics,
205
+ Nodes: facts.Nodes,
206
+ Edges: facts.Edges,
207
+ }, nil
208
+ }
209
+
210
+ // NewDumpFacts projects only one graph's nodes and edges through the complete
211
+ // dump path/evidence codec. The caller may pass a partial graph produced by
212
+ // BuildFiles; no project-wide provenance, diagnostic walk or full Dump is
213
+ // constructed.
214
+ func NewDumpFacts(g *Graph, project string, ignored map[string]bool, sources map[string]string) (DumpFacts, error) {
215
+ facts, _, err := newDumpFacts(g, project, ignored, sources)
216
+ return facts, err
217
+ }
218
+
219
+ func newDumpFacts(g *Graph, project string, ignored map[string]bool, sources map[string]string) (DumpFacts, *dumpContext, error) {
156
220
  ctx := newDumpContext(project, sources)
157
221
 
158
222
  // Decorators ride on their target node; group by the internal node id before
@@ -203,11 +267,13 @@ func NewDump(g *Graph, project, tsconfig string, ignored map[string]bool, source
203
267
 
204
268
  edges := make([]DumpEdge, 0, len(g.Edges))
205
269
  for _, e := range g.Edges {
270
+ includeEvidenceFile := e.File != "" && e.File != nodeFile(e.From)
271
+ evidence := ctx.edgeEvidence(e, includeEvidenceFile)
206
272
  edges = append(edges, DumpEdge{
207
273
  From: ctx.relID(e.From),
208
274
  To: ctx.relID(e.To),
209
275
  Kind: dumpEdgeKind(e),
210
- Evidence: withoutFile(ctx.edgeEvidence(e)),
276
+ Evidence: evidence,
211
277
  })
212
278
  }
213
279
  sort.Slice(edges, func(i, j int) bool {
@@ -220,44 +286,10 @@ func NewDump(g *Graph, project, tsconfig string, ignored map[string]bool, source
220
286
  return edges[i].Kind < edges[j].Kind
221
287
  })
222
288
 
223
- // The schema version describes this code, not the caller's belief about it,
224
- // so stamp it here rather than trusting what came in.
225
- provenance := ctx.provenance(origin.Provenance)
226
- provenance.SchemaVersion = DumpSchemaVersion
227
-
228
- // A nil slice encodes as JSON null, and null is not an empty list: a reader
229
- // validating `string[]` rejects it, and a reader that does not would have to
230
- // guess which of the two the producer meant. Every list on the wire is a list.
231
- if provenance.Capabilities == nil {
232
- provenance.Capabilities = []string{}
233
- }
234
- if provenance.Sources == nil {
235
- provenance.Sources = []SourceDigest{}
236
- }
237
- if provenance.Universe.Configs == nil {
238
- provenance.Universe.Configs = []FileDigest{}
239
- }
240
- if provenance.Universe.Roots == nil {
241
- provenance.Universe.Roots = []RootFile{}
242
- }
243
-
244
- diagnostics := ctx.diagnostics(origin.Diagnostics)
245
- if diagnostics == nil {
246
- diagnostics = []Diagnostic{}
247
- }
248
-
249
- dump := Dump{
250
- Project: project,
251
- Tsconfig: ctx.rel(tsconfig),
252
- Provenance: provenance,
253
- Diagnostics: diagnostics,
254
- Nodes: nodes,
255
- Edges: edges,
256
- }
257
289
  if err := ctx.pathError(); err != nil {
258
- return Dump{}, err
290
+ return DumpFacts{}, ctx, err
259
291
  }
260
- return dump, nil
292
+ return DumpFacts{Nodes: nodes, Edges: edges}, ctx, nil
261
293
  }
262
294
 
263
295
  // dumpEnumMembers projects an enum's members onto the wire shape, nil for a
@@ -443,6 +475,10 @@ func (c *dumpContext) relID(id string) string {
443
475
  // evidence builds the line/col span for a byte range in file, or nil when the
444
476
  // span is absent or no source is available.
445
477
  func (c *dumpContext) evidence(file string, pos, end int) *DumpEvidence {
478
+ return c.evidenceWithFile(file, pos, end, true)
479
+ }
480
+
481
+ func (c *dumpContext) evidenceWithFile(file string, pos, end int, includeFile bool) *DumpEvidence {
446
482
  if pos < 0 || c.sources == nil {
447
483
  return nil
448
484
  }
@@ -464,7 +500,10 @@ func (c *dumpContext) evidence(file string, pos, end int) *DumpEvidence {
464
500
  // or its indentation.
465
501
  pos = FirstCodeOffset(text, pos)
466
502
  sl, sc := ls.at(pos)
467
- ev := &DumpEvidence{File: c.rel(file), StartLine: sl, StartCol: sc}
503
+ ev := &DumpEvidence{StartLine: sl, StartCol: sc}
504
+ if includeFile {
505
+ ev.File = c.rel(file)
506
+ }
468
507
  if end > pos && end <= len(text) {
469
508
  ev.EndLine, ev.EndCol = ls.at(end)
470
509
  }
@@ -646,13 +685,15 @@ func regularExpressionEnd(text string, start int) int {
646
685
  }
647
686
 
648
687
  // edgeEvidence is the evidence range for an edge's source expression.
649
- func (c *dumpContext) edgeEvidence(e *Edge) *DumpEvidence {
650
- // The edge's span lives in the From node's file.
651
- file := nodeFile(e.From)
688
+ func (c *dumpContext) edgeEvidence(e *Edge, includeFile bool) *DumpEvidence {
689
+ file := e.File
690
+ if file == "" {
691
+ file = nodeFile(e.From)
692
+ }
652
693
  if file == "" {
653
694
  return nil
654
695
  }
655
- return c.evidence(file, e.Pos, e.End)
696
+ return c.evidenceWithFile(file, e.Pos, e.End, includeFile)
656
697
  }
657
698
 
658
699
  // withoutFile drops a span file the reader reconstructs from the node or edge
@@ -0,0 +1,29 @@
1
+ package graph
2
+
3
+ import "testing"
4
+
5
+ // TestDumpPathMapperCachesAliasResolution verifies repeated graph facts do not
6
+ // repeat filesystem canonicalization for the same raw source path.
7
+ //
8
+ // A large graph maps one source through its node, IDs and many edge endpoints;
9
+ // caching only after EvalSymlinks would turn those facts into repeated syscalls.
10
+ //
11
+ // 1. Install a counting canonicalizer on one mapper.
12
+ // 2. Map the same absolute source path repeatedly.
13
+ // 3. Require one canonicalization and one stable wire coordinate.
14
+ func TestDumpPathMapperCachesAliasResolution(t *testing.T) {
15
+ mapper := newDumpPathMapper("C:/checkout/app")
16
+ calls := 0
17
+ mapper.canonicalize = func(path string) string {
18
+ calls++
19
+ return path
20
+ }
21
+ for range 100 {
22
+ if wire := mapper.mapPath("C:/checkout/app/src/main.ts"); wire != "src/main.ts" {
23
+ t.Fatalf("wire path = %q, want src/main.ts", wire)
24
+ }
25
+ }
26
+ if calls != 1 {
27
+ t.Fatalf("canonicalizer calls = %d, want 1", calls)
28
+ }
29
+ }
@@ -2,6 +2,8 @@ package graph
2
2
 
3
3
  import (
4
4
  "fmt"
5
+ "path/filepath"
6
+ "runtime"
5
7
  "strings"
6
8
 
7
9
  shimtspath "github.com/microsoft/typescript-go/shim/tspath"
@@ -12,19 +14,26 @@ import (
12
14
  // reject both an unportable filesystem root and a non-injective projection
13
15
  // before any JSON is written.
14
16
  type dumpPathMapper struct {
17
+ rawProject string
15
18
  project string
16
19
  caseSensitive bool
20
+ canonicalize func(string) string
17
21
 
22
+ rawToWire map[string]string
18
23
  physicalToWire map[string]string
19
24
  wireToPhysical map[string]string
20
25
  mappingErr error
21
26
  }
22
27
 
23
28
  func newDumpPathMapper(project string) *dumpPathMapper {
24
- normalized := shimtspath.NormalizePath(shimtspath.NormalizeSlashes(project))
29
+ raw := shimtspath.NormalizePath(shimtspath.NormalizeSlashes(project))
30
+ normalized := canonicalDumpPath(raw)
25
31
  mapper := &dumpPathMapper{
32
+ rawProject: raw,
26
33
  project: normalized,
27
34
  caseSensitive: dumpPathRootIsCaseSensitive(normalized),
35
+ canonicalize: canonicalDumpPath,
36
+ rawToWire: map[string]string{},
28
37
  physicalToWire: map[string]string{},
29
38
  wireToPhysical: map[string]string{},
30
39
  }
@@ -34,6 +43,62 @@ func newDumpPathMapper(project string) *dumpPathMapper {
34
43
  return mapper
35
44
  }
36
45
 
46
+ // WireProject returns the canonical filesystem base that owns every relative
47
+ // wire path emitted by this package.
48
+ func WireProject(project string) (string, error) {
49
+ mapper := newDumpPathMapper(project)
50
+ return mapper.project, mapper.err()
51
+ }
52
+
53
+ // WirePath maps one compiler path into the portable schema-v6 vocabulary used
54
+ // by dumps and resident graph shards. Callers that project a complete graph
55
+ // keep one dumpPathMapper so collision detection spans every path; callers
56
+ // shaping a single source/config coordinate use this helper and receive the
57
+ // same filesystem-alias and cross-root behavior.
58
+ func WirePath(project, file string) (string, error) {
59
+ mapper := newDumpPathMapper(project)
60
+ wire := mapper.mapPath(file)
61
+ return wire, mapper.err()
62
+ }
63
+
64
+ // WireNodeID maps the filesystem-bearing portions of one internal node ID into
65
+ // the same portable vocabulary as NewDumpFacts. Resident stores use it when a
66
+ // wire-level external reference count must be reconciled with the immutable
67
+ // graph.Node cache, whose keys retain compiler-physical paths.
68
+ func WireNodeID(project, id string) (string, error) {
69
+ ids, err := WireNodeIDs(project, []string{id})
70
+ return ids[id], err
71
+ }
72
+
73
+ // WireNodeIDs maps a set of internal node IDs through one path mapper. Sharing
74
+ // the mapper preserves cross-ID collision detection and resolves each repeated
75
+ // filesystem alias only once per snapshot generation.
76
+ func WireNodeIDs(project string, ids []string) (map[string]string, error) {
77
+ mapper := newDumpPathMapper(project)
78
+ wireIDs := make(map[string]string, len(ids))
79
+ for _, id := range ids {
80
+ wire, err := wireNodeID(mapper, id)
81
+ if err != nil {
82
+ return nil, err
83
+ }
84
+ wireIDs[id] = wire
85
+ }
86
+ return wireIDs, mapper.err()
87
+ }
88
+
89
+ func wireNodeID(mapper *dumpPathMapper, id string) (string, error) {
90
+ parts, ok := parseNodeID(id)
91
+ if !ok {
92
+ return "", fmt.Errorf("ttscgraph: invalid internal graph node id %q", id)
93
+ }
94
+ name := parts.name
95
+ if parts.kind == NodeModule {
96
+ name = mapper.mapPath(name)
97
+ }
98
+ wire := nodeID(mapper.mapPath(parts.path), name, parts.kind)
99
+ return wire, nil
100
+ }
101
+
37
102
  // mapPath returns one portable, slash-normalized coordinate:
38
103
  //
39
104
  // - project files stay project-relative;
@@ -49,26 +114,30 @@ func (m *dumpPathMapper) mapPath(file string) string {
49
114
  if file == "" {
50
115
  return ""
51
116
  }
52
- normalized := shimtspath.NormalizeSlashes(file)
117
+ normalized := shimtspath.NormalizePath(shimtspath.NormalizeSlashes(file))
53
118
  if strings.HasPrefix(normalized, "bundled:///") {
54
119
  return m.claim(normalized, normalized)
55
120
  }
56
- normalized = shimtspath.NormalizePath(normalized)
57
121
  if m.project == "" || shimtspath.GetRootLength(m.project) == 0 {
58
122
  return normalized
59
123
  }
60
124
 
61
- physical := normalized
62
- if shimtspath.GetRootLength(physical) == 0 {
63
- physical = shimtspath.GetNormalizedAbsolutePath(physical, m.project)
125
+ rawPhysical := normalized
126
+ if shimtspath.GetRootLength(rawPhysical) == 0 {
127
+ rawPhysical = shimtspath.GetNormalizedAbsolutePath(rawPhysical, m.rawProject)
64
128
  }
129
+ rawKey := m.pathKey(rawPhysical)
130
+ if wire, ok := m.rawToWire[rawKey]; ok {
131
+ return wire
132
+ }
133
+ physical := m.canonicalize(rawPhysical)
65
134
  if !dumpPathRootsEqual(m.project, physical, m.caseSensitive) {
66
135
  m.fail(fmt.Errorf(
67
136
  "ttscgraph: source path %q cannot be represented relative to project %q because they are on different filesystem roots",
68
- normalized,
137
+ rawPhysical,
69
138
  m.project,
70
139
  ))
71
- return normalized
140
+ return rawPhysical
72
141
  }
73
142
  options := shimtspath.ComparePathsOptions{
74
143
  CurrentDirectory: m.project,
@@ -78,12 +147,21 @@ func (m *dumpPathMapper) mapPath(file string) string {
78
147
  if shimtspath.GetRootLength(wire) != 0 {
79
148
  m.fail(fmt.Errorf(
80
149
  "ttscgraph: source path %q cannot be represented relative to project %q because they are on different filesystem roots",
81
- normalized,
150
+ rawPhysical,
82
151
  m.project,
83
152
  ))
84
- return normalized
153
+ return rawPhysical
85
154
  }
86
- return m.claim(physical, wire)
155
+ wire = m.claim(physical, wire)
156
+ m.rawToWire[rawKey] = wire
157
+ return wire
158
+ }
159
+
160
+ func (m *dumpPathMapper) pathKey(path string) string {
161
+ if !m.caseSensitive {
162
+ return strings.ToLower(path)
163
+ }
164
+ return path
87
165
  }
88
166
 
89
167
  // claim records both directions of the projection. The reverse map is the
@@ -127,6 +205,51 @@ func (m *dumpPathMapper) fail(err error) {
127
205
 
128
206
  func (m *dumpPathMapper) err() error { return m.mappingErr }
129
207
 
208
+ // canonicalDumpPath collapses filesystem aliases for an existing path before
209
+ // it enters the wire-coordinate mapper. TypeScript reports physical source
210
+ // names, while a caller may select the same project through a symlink or a
211
+ // Windows 8.3 spelling; comparing those raw strings would leak a producer-local
212
+ // absolute path into an otherwise portable snapshot.
213
+ //
214
+ // Synthetic paths in mapper unit tests and missing paths retain their lexical
215
+ // spelling. Real graph inputs exist by construction, so the best-effort branch
216
+ // covers their physical identity without weakening the mapper's explicit
217
+ // cross-root and collision checks.
218
+ func canonicalDumpPath(location string) string {
219
+ normalized := shimtspath.NormalizePath(shimtspath.NormalizeSlashes(location))
220
+ if normalized == "" || shimtspath.GetRootLength(normalized) == 0 {
221
+ return normalized
222
+ }
223
+ if !dumpPathUsesHostFilesystem(normalized) {
224
+ return normalized
225
+ }
226
+ candidate := filepath.Clean(filepath.FromSlash(normalized))
227
+ suffix := []string{}
228
+ for {
229
+ physical, err := filepath.EvalSymlinks(candidate)
230
+ if err == nil {
231
+ for index := len(suffix) - 1; index >= 0; index-- {
232
+ physical = filepath.Join(physical, suffix[index])
233
+ }
234
+ return shimtspath.NormalizePath(shimtspath.NormalizeSlashes(physical))
235
+ }
236
+ parent := filepath.Dir(candidate)
237
+ if parent == candidate {
238
+ break
239
+ }
240
+ suffix = append(suffix, filepath.Base(candidate))
241
+ candidate = parent
242
+ }
243
+ return normalized
244
+ }
245
+
246
+ func dumpPathUsesHostFilesystem(path string) bool {
247
+ if runtime.GOOS == "windows" {
248
+ return strings.HasPrefix(path, "//") || (len(path) >= 2 && path[1] == ':')
249
+ }
250
+ return strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "//")
251
+ }
252
+
130
253
  // Windows drive and UNC roots use case-insensitive path comparison. POSIX
131
254
  // roots remain case-sensitive. This decision follows the path's own grammar so
132
255
  // synthetic Windows/UNC fixtures behave the same on every CI host.
@@ -0,0 +1,39 @@
1
+ //go:build windows
2
+
3
+ package graph
4
+
5
+ import (
6
+ "path/filepath"
7
+ "testing"
8
+ )
9
+
10
+ // TestDumpPathMapperCanonicalizesWindowsShortRoot verifies that a project
11
+ // selected through an 8.3 path still owns checker sources reported through the
12
+ // expanded physical spelling.
13
+ //
14
+ // 1. Obtain the environment's temp spelling and its physical spelling.
15
+ // 2. Skip hosts where Windows exposes only one spelling.
16
+ // 3. Require a physical child source to retain a project-relative identity.
17
+ func TestDumpPathMapperCanonicalizesWindowsShortRoot(t *testing.T) {
18
+ project := t.TempDir()
19
+ physical, err := filepath.EvalSymlinks(project)
20
+ if err != nil {
21
+ t.Fatal(err)
22
+ }
23
+ if filepath.Clean(project) == filepath.Clean(physical) {
24
+ t.Skip("temporary directory has no alternate Windows path spelling")
25
+ }
26
+
27
+ mapper := newDumpPathMapper(project)
28
+ source := filepath.Join(physical, "src", "main.ts")
29
+ if got := mapper.mapPath(source); got != "src/main.ts" {
30
+ t.Fatalf("mapPath(%q) = %q, want project-relative identity", source, got)
31
+ }
32
+ missing := filepath.Join(project, "future", "main.ts")
33
+ if got := mapper.mapPath(missing); got != "future/main.ts" {
34
+ t.Fatalf("mapPath(%q) = %q, want stable missing-root identity", missing, got)
35
+ }
36
+ if err := mapper.err(); err != nil {
37
+ t.Fatal(err)
38
+ }
39
+ }
@@ -0,0 +1,32 @@
1
+ package graph
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+ )
7
+
8
+ // TestNewDumpRejectsProvenanceOnlyPathErrors verifies paths introduced after
9
+ // fact projection still pass through the dump context's fail-closed boundary.
10
+ //
11
+ // Provenance inputs are mapped after graph facts. Without a final path-error
12
+ // check, a cross-drive config could survive into otherwise valid JSON.
13
+ //
14
+ // 1. Build an empty graph under one synthetic Windows drive.
15
+ // 2. Add a provenance-only config input from another drive.
16
+ // 3. Require NewDump to reject the unrepresentable coordinate.
17
+ func TestNewDumpRejectsProvenanceOnlyPathErrors(t *testing.T) {
18
+ _, err := NewDump(
19
+ &Graph{Nodes: map[string]*Node{}},
20
+ "C:/checkout/app",
21
+ "tsconfig.json",
22
+ nil,
23
+ nil,
24
+ DumpOrigin{Provenance: Provenance{Universe: Universe{Configs: []FileDigest{{
25
+ File: "D:/shared/tsconfig.json",
26
+ Digest: "config-digest",
27
+ }}}}},
28
+ )
29
+ if err == nil || !strings.Contains(err.Error(), "different filesystem roots") {
30
+ t.Fatalf("provenance-only cross-root error = %v, want rejection before serialization", err)
31
+ }
32
+ }
@@ -55,8 +55,8 @@ export function main(): void {
55
55
  if err := json.Unmarshal(data, &dump); err != nil {
56
56
  t.Fatalf("dump is not valid JSON: %v\n%s", err, data)
57
57
  }
58
- if dump.Project != root || dump.Tsconfig != "tsconfig.json" {
59
- t.Fatalf("project/tsconfig not echoed: %q / %q", dump.Project, dump.Tsconfig)
58
+ if dump.Project != canonicalDumpPath(root) || dump.Tsconfig != "tsconfig.json" {
59
+ t.Fatalf("project/tsconfig coordinates are not canonical: %q / %q", dump.Project, dump.Tsconfig)
60
60
  }
61
61
  if len(dump.Nodes) != len(g.Nodes) {
62
62
  t.Fatalf("dumped %d nodes, graph has %d", len(dump.Nodes), len(g.Nodes))