ttsc 0.23.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.
- package/cmd/ttscgraph/serve.go +264 -48
- package/cmd/ttscgraph/serve_early_error_encode_failure_is_observable_test.go +40 -0
- package/cmd/ttscgraph/serve_graph_snapshot_version_matches_typescript_client_test.go +32 -0
- package/cmd/ttscgraph/serve_phase_trace_accounts_for_load_failure_test.go +45 -0
- package/cmd/ttscgraph/serve_phase_trace_is_opt_in_and_payload_free_test.go +52 -0
- package/cmd/ttscgraph/serve_protocol_negotiates_incremental_shards_test.go +44 -0
- package/cmd/ttscgraph/serve_session_reloads_commonjs_package_entry_for_esm_test.go +2 -2
- package/cmd/ttscgraph/serve_session_reloads_superseding_module_candidates_test.go +46 -20
- package/cmd/ttscgraph/serve_session_retries_failed_dump_test.go +9 -9
- package/cmd/ttscgraph/serve_shards.go +1390 -0
- package/cmd/ttscgraph/serve_shards_abandoned_generation_converges_on_retry_test.go +92 -0
- package/cmd/ttscgraph/serve_shards_delete_removed_source_test.go +57 -0
- package/cmd/ttscgraph/serve_shards_external_endpoints_test.go +53 -0
- package/cmd/ttscgraph/serve_shards_keep_source_distributed_dependencies_at_the_boundary_test.go +108 -0
- package/cmd/ttscgraph/serve_shards_match_full_dump_oracle_test.go +88 -0
- package/cmd/ttscgraph/serve_shards_merge_pending_and_new_edits_test.go +50 -0
- package/cmd/ttscgraph/serve_shards_prune_unreferenced_external_endpoints_test.go +65 -0
- package/cmd/ttscgraph/serve_shards_publish_created_source_test.go +92 -0
- package/cmd/ttscgraph/serve_shards_rebuild_declaration_edit_test.go +43 -0
- package/cmd/ttscgraph/serve_shards_refresh_cross_file_implementation_test.go +63 -0
- package/cmd/ttscgraph/serve_shards_refresh_dependent_diagnostics_test.go +80 -0
- package/cmd/ttscgraph/serve_shards_rename_moves_source_identity_test.go +95 -0
- package/cmd/ttscgraph/serve_shards_retry_preserves_committed_generation_test.go +48 -0
- package/cmd/ttscgraph/serve_shards_reuse_unaffected_sources_test.go +121 -0
- package/driver/declaration_shape.go +85 -0
- package/driver/program.go +39 -10
- package/internal/graph/build.go +62 -10
- package/internal/graph/build_files_reuses_committed_cross_file_endpoints_test.go +87 -0
- package/internal/graph/build_keeps_source_distributed_dependencies_at_the_external_boundary_test.go +74 -0
- package/internal/graph/dump.go +84 -43
- package/internal/graph/dump_path_mapper_caches_alias_resolution_test.go +29 -0
- package/internal/graph/dump_paths.go +134 -11
- package/internal/graph/dump_paths_canonicalize_windows_short_root_test.go +39 -0
- package/internal/graph/dump_rejects_provenance_only_path_errors_test.go +32 -0
- package/internal/graph/dump_serializes_the_full_graph_test.go +2 -2
- package/internal/graph/dump_symlink_project_publishes_matching_base_test.go +82 -0
- package/internal/graph/edges.go +58 -16
- package/internal/graph/exports.go +9 -2
- package/internal/graph/graph.go +47 -7
- package/internal/graph/graph_kind_contracts_match_their_producers_test.go +121 -0
- package/internal/graph/node_modifiers_emit_union_strings_test.go +1 -1
- package/internal/graph/provenance.go +11 -1
- package/internal/graph/resolve.go +1 -4
- package/internal/graph/source.go +23 -0
- package/internal/graph/wire_node_ids_share_alias_resolution_test.go +29 -0
- package/package.json +8 -8
|
@@ -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
|
+
}
|
package/internal/graph/build_keeps_source_distributed_dependencies_at_the_external_boundary_test.go
ADDED
|
@@ -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
|
+
}
|
package/internal/graph/dump.go
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
290
|
+
return DumpFacts{}, ctx, err
|
|
259
291
|
}
|
|
260
|
-
return
|
|
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{
|
|
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
|
-
|
|
651
|
-
file
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
if shimtspath.GetRootLength(
|
|
63
|
-
|
|
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
|
-
|
|
137
|
+
rawPhysical,
|
|
69
138
|
m.project,
|
|
70
139
|
))
|
|
71
|
-
return
|
|
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
|
-
|
|
150
|
+
rawPhysical,
|
|
82
151
|
m.project,
|
|
83
152
|
))
|
|
84
|
-
return
|
|
153
|
+
return rawPhysical
|
|
85
154
|
}
|
|
86
|
-
|
|
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
|
|
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))
|