ttsc 0.19.1 → 0.19.2
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/README.md +68 -186
- package/cmd/graphdump/main.go +21 -1
- package/cmd/ttsc/api_compile.go +3 -13
- package/cmd/ttsc/api_transform.go +13 -0
- package/cmd/ttscgraph/dump.go +40 -1
- package/cmd/ttscgraph/serve.go +194 -25
- package/cmd/ttscgraph/serve_error_response_still_names_its_mode_test.go +73 -0
- package/cmd/ttscgraph/serve_protocol_fails_the_stream_on_an_unaddressable_request_line_test.go +43 -0
- package/cmd/ttscgraph/serve_protocol_version_matches_the_typescript_client_test.go +48 -0
- package/cmd/ttscgraph/serve_snapshot_proves_its_program_without_a_second_read_test.go +109 -0
- package/driver/graph.go +133 -0
- package/internal/graph/diagnostics.go +6 -5
- package/internal/graph/dump.go +69 -17
- package/internal/graph/dump_diagnostics_ride_the_generation_that_built_it_test.go +79 -0
- package/internal/graph/dump_evidence_skips_leading_trivia_test.go +1 -1
- package/internal/graph/dump_is_a_function_of_the_source_test.go +1 -1
- package/internal/graph/dump_never_serializes_a_list_as_null_test.go +76 -0
- package/internal/graph/dump_schema_version_matches_the_typescript_client_test.go +48 -0
- package/internal/graph/dump_serializes_the_full_graph_test.go +21 -7
- package/internal/graph/node_modifiers_emit_union_strings_test.go +1 -1
- package/internal/graph/provenance.go +312 -0
- package/lib/TtscCompiler.js +9 -4
- package/lib/TtscCompiler.js.map +1 -1
- package/lib/compiler/internal/transformProjectInMemory.d.ts +4 -0
- package/lib/compiler/internal/transformProjectInMemory.js +67 -12
- package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
- package/lib/structures/ITtscCompilerTransformation.d.ts +98 -0
- package/package.json +8 -8
- package/shim/compiler/incremental.go +58 -0
- package/shim/core/shim.go +5 -0
- package/src/TtscCompiler.ts +19 -4
- package/src/compiler/internal/transformProjectInMemory.ts +106 -12
- package/src/structures/ITtscCompilerTransformation.ts +107 -0
- package/tools/shim_audit/baseline.json +2 -1
- package/tools/shim_audit/main.go +17 -0
- package/utility/host.go +17 -15
- package/cmd/ttscgraph/serve_protocol_rejects_invalid_request_line_test.go +0 -43
package/cmd/ttscgraph/serve.go
CHANGED
|
@@ -22,16 +22,91 @@ import (
|
|
|
22
22
|
"github.com/samchon/ttsc/packages/ttsc/internal/graph"
|
|
23
23
|
)
|
|
24
24
|
|
|
25
|
+
// serveProtocolVersion is the version of the newline-delimited serve envelope.
|
|
26
|
+
// It moves when a field is added, removed, or given a new meaning.
|
|
27
|
+
//
|
|
28
|
+
// Every response carries it, rather than a handshake establishing it once. The
|
|
29
|
+
// binary and the npm package version independently — @ttsc/graph resolves
|
|
30
|
+
// whichever ttscgraph the target project installed — so a mismatched pair is
|
|
31
|
+
// reachable, and a per-response version lets the client fail fast on the first
|
|
32
|
+
// frame it reads without spending a round-trip to learn the version it is about
|
|
33
|
+
// to be told anyway. It also keeps the protocol stateless: a response is
|
|
34
|
+
// self-describing even when read from a log.
|
|
35
|
+
const serveProtocolVersion = 1
|
|
36
|
+
|
|
37
|
+
// serveModes are the computation modes Snapshot can report, plus the error mode
|
|
38
|
+
// the transport adds. A consumer branches on these to report honestly what the
|
|
39
|
+
// producer did rather than inferring it from a generation counter.
|
|
40
|
+
const (
|
|
41
|
+
// serveModeInitial is the first snapshot of a session.
|
|
42
|
+
serveModeInitial = "initial"
|
|
43
|
+
// serveModeReload is a full program reload: the build universe moved.
|
|
44
|
+
serveModeReload = "reload"
|
|
45
|
+
// serveModeUnchanged is no change since the last snapshot; no dump rides it.
|
|
46
|
+
serveModeUnchanged = "unchanged"
|
|
47
|
+
// serveModeIncremental is edits applied onto the reused resident program.
|
|
48
|
+
serveModeIncremental = "incremental"
|
|
49
|
+
// serveModeRebuild is edits applied but the program could not be reused.
|
|
50
|
+
serveModeRebuild = "rebuild"
|
|
51
|
+
// serveModeError is a request that produced no snapshot. It is a transport
|
|
52
|
+
// mode, not a computation mode: it exists so mode is never absent, because a
|
|
53
|
+
// field that disappears on the error path cannot be relied on.
|
|
54
|
+
serveModeError = "error"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// fullSnapshotCapabilities is what a snapshot from a resident compiler session
|
|
58
|
+
// proves. Both commands that own a real Program declare all three; the constant
|
|
59
|
+
// exists so the envelope's claim and the dump's claim cannot drift apart.
|
|
60
|
+
var fullSnapshotCapabilities = []string{
|
|
61
|
+
graph.CapabilityUniverse,
|
|
62
|
+
graph.CapabilitySourceDigests,
|
|
63
|
+
graph.CapabilityDiskDigests,
|
|
64
|
+
graph.CapabilityDiagnostics,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// serveCapabilities is what this server can prove, answered before a consumer
|
|
68
|
+
// has a dump to inspect — an `unchanged` response carries no dump, and a client
|
|
69
|
+
// negotiating on the first frame has not parsed one yet. It mirrors what every
|
|
70
|
+
// dump this server publishes declares for itself.
|
|
71
|
+
var serveCapabilities = fullSnapshotCapabilities
|
|
72
|
+
|
|
25
73
|
type serveRequest struct {
|
|
26
74
|
ID int `json:"id"`
|
|
27
75
|
}
|
|
28
76
|
|
|
29
77
|
type serveResponse struct {
|
|
30
|
-
Dump
|
|
31
|
-
Error
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
78
|
+
Dump *graph.Dump `json:"dump,omitempty"`
|
|
79
|
+
// Error is set when the request produced no snapshot; Mode is then
|
|
80
|
+
// serveModeError.
|
|
81
|
+
Error string `json:"error,omitempty"`
|
|
82
|
+
ID int `json:"id"`
|
|
83
|
+
// ProtocolVersion is serveProtocolVersion on every response, including error
|
|
84
|
+
// responses: a client that cannot parse the rest still learns why.
|
|
85
|
+
ProtocolVersion int `json:"protocolVersion"`
|
|
86
|
+
// Mode is always present. It was omitempty, which meant the one field that
|
|
87
|
+
// distinguishes a reuse from a full rebuild silently vanished exactly when a
|
|
88
|
+
// consumer most wanted to report what happened.
|
|
89
|
+
Mode string `json:"mode"`
|
|
90
|
+
Capabilities []string `json:"capabilities"`
|
|
91
|
+
Changed bool `json:"changed"`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// newServeResponse stamps the fields every response owes the client, so no exit
|
|
95
|
+
// from the serve loop can forget one.
|
|
96
|
+
func newServeResponse(id int) serveResponse {
|
|
97
|
+
return serveResponse{
|
|
98
|
+
ID: id,
|
|
99
|
+
ProtocolVersion: serveProtocolVersion,
|
|
100
|
+
Capabilities: serveCapabilities,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// errorResponse is a response that carries no snapshot.
|
|
105
|
+
func errorResponse(id int, message string) serveResponse {
|
|
106
|
+
response := newServeResponse(id)
|
|
107
|
+
response.Mode = serveModeError
|
|
108
|
+
response.Error = message
|
|
109
|
+
return response
|
|
35
110
|
}
|
|
36
111
|
|
|
37
112
|
type graphSession struct {
|
|
@@ -42,7 +117,17 @@ type graphSession struct {
|
|
|
42
117
|
auxStates map[string]diskState
|
|
43
118
|
sourceHashes map[string][sha256.Size]byte
|
|
44
119
|
rootFiles []string
|
|
45
|
-
|
|
120
|
+
// diskDigests is the published disk evidence for the current generation, kept
|
|
121
|
+
// beside sourceHashes because the two answer different questions: one decides
|
|
122
|
+
// whether to invalidate, the other is what the snapshot tells a consumer.
|
|
123
|
+
diskDigests map[string]string
|
|
124
|
+
// configDigests and roots are the build-universe fingerprint for the current
|
|
125
|
+
// generation, captured from the same parse that produced configHashes and
|
|
126
|
+
// rootFiles so the published evidence and the invalidation state can never
|
|
127
|
+
// describe different loads.
|
|
128
|
+
configDigests []graph.FileDigest
|
|
129
|
+
roots []graph.RootFile
|
|
130
|
+
initialized bool
|
|
46
131
|
}
|
|
47
132
|
|
|
48
133
|
func newGraphSession(cwd, tsconfig string) (*graphSession, error) {
|
|
@@ -64,7 +149,7 @@ func (s *graphSession) Snapshot() (*graph.Dump, string, bool, error) {
|
|
|
64
149
|
if !s.initialized {
|
|
65
150
|
dump := s.buildDump()
|
|
66
151
|
s.initialized = true
|
|
67
|
-
return &dump,
|
|
152
|
+
return &dump, serveModeInitial, true, nil
|
|
68
153
|
}
|
|
69
154
|
|
|
70
155
|
configChanged, err := hashesChanged(s.configHashes)
|
|
@@ -76,7 +161,7 @@ func (s *graphSession) Snapshot() (*graph.Dump, string, bool, error) {
|
|
|
76
161
|
return nil, "", false, err
|
|
77
162
|
}
|
|
78
163
|
dump := s.buildDump()
|
|
79
|
-
return &dump,
|
|
164
|
+
return &dump, serveModeReload, true, nil
|
|
80
165
|
}
|
|
81
166
|
|
|
82
167
|
if diskStatesChanged(s.auxStates) {
|
|
@@ -84,7 +169,7 @@ func (s *graphSession) Snapshot() (*graph.Dump, string, bool, error) {
|
|
|
84
169
|
return nil, "", false, err
|
|
85
170
|
}
|
|
86
171
|
dump := s.buildDump()
|
|
87
|
-
return &dump,
|
|
172
|
+
return &dump, serveModeReload, true, nil
|
|
88
173
|
}
|
|
89
174
|
|
|
90
175
|
roots, err := projectRootFiles(s.compiler.Program(), true)
|
|
@@ -96,7 +181,7 @@ func (s *graphSession) Snapshot() (*graph.Dump, string, bool, error) {
|
|
|
96
181
|
return nil, "", false, err
|
|
97
182
|
}
|
|
98
183
|
dump := s.buildDump()
|
|
99
|
-
return &dump,
|
|
184
|
+
return &dump, serveModeReload, true, nil
|
|
100
185
|
}
|
|
101
186
|
|
|
102
187
|
changed, deleted, err := changedSources(s.sourceHashes)
|
|
@@ -108,20 +193,20 @@ func (s *graphSession) Snapshot() (*graph.Dump, string, bool, error) {
|
|
|
108
193
|
return nil, "", false, err
|
|
109
194
|
}
|
|
110
195
|
dump := s.buildDump()
|
|
111
|
-
return &dump,
|
|
196
|
+
return &dump, serveModeReload, true, nil
|
|
112
197
|
}
|
|
113
198
|
if len(changed) == 0 {
|
|
114
|
-
return nil,
|
|
199
|
+
return nil, serveModeUnchanged, false, nil
|
|
115
200
|
}
|
|
116
201
|
if s.compiler.Program().HasLinkedProgramPlugins() {
|
|
117
202
|
if err := s.reload(); err != nil {
|
|
118
203
|
return nil, "", false, err
|
|
119
204
|
}
|
|
120
205
|
dump := s.buildDump()
|
|
121
|
-
return &dump,
|
|
206
|
+
return &dump, serveModeReload, true, nil
|
|
122
207
|
}
|
|
123
208
|
|
|
124
|
-
mode :=
|
|
209
|
+
mode := serveModeIncremental
|
|
125
210
|
paths := make([]string, 0, len(changed))
|
|
126
211
|
for path := range changed {
|
|
127
212
|
paths = append(paths, path)
|
|
@@ -129,7 +214,7 @@ func (s *graphSession) Snapshot() (*graph.Dump, string, bool, error) {
|
|
|
129
214
|
sort.Strings(paths)
|
|
130
215
|
for _, path := range paths {
|
|
131
216
|
if reused := s.compiler.Apply(path, changed[path]); !reused {
|
|
132
|
-
mode =
|
|
217
|
+
mode = serveModeRebuild
|
|
133
218
|
}
|
|
134
219
|
current, exists := s.compiler.SourceText(path)
|
|
135
220
|
expected := driver.ApplySourcePreambleToFile(path, changed[path], s.compiler.Program().SourcePreamble)
|
|
@@ -138,7 +223,7 @@ func (s *graphSession) Snapshot() (*graph.Dump, string, bool, error) {
|
|
|
138
223
|
return nil, "", false, err
|
|
139
224
|
}
|
|
140
225
|
dump := s.buildDump()
|
|
141
|
-
return &dump,
|
|
226
|
+
return &dump, serveModeReload, true, nil
|
|
142
227
|
}
|
|
143
228
|
}
|
|
144
229
|
if err := s.captureState(); err != nil {
|
|
@@ -182,7 +267,7 @@ func (s *graphSession) captureState() error {
|
|
|
182
267
|
if err != nil {
|
|
183
268
|
return err
|
|
184
269
|
}
|
|
185
|
-
sourceHashes, err := hashProgramSources(program)
|
|
270
|
+
sourceHashes, diskDigests, err := hashProgramSources(program)
|
|
186
271
|
if err != nil {
|
|
187
272
|
return err
|
|
188
273
|
}
|
|
@@ -191,10 +276,37 @@ func (s *graphSession) captureState() error {
|
|
|
191
276
|
s.configHashes = configHashes
|
|
192
277
|
s.auxStates = captureDiskStates(compactSortedStrings(inputs))
|
|
193
278
|
s.sourceHashes = sourceHashes
|
|
279
|
+
s.diskDigests = diskDigests
|
|
194
280
|
s.rootFiles = projectRootFilesFromConfigs(configs, false)
|
|
281
|
+
s.configDigests = fileDigests(configHashes)
|
|
282
|
+
s.roots = rootFileEntries(s.rootFiles)
|
|
195
283
|
return nil
|
|
196
284
|
}
|
|
197
285
|
|
|
286
|
+
// fileDigests projects a path-to-hash map onto the wire's file/digest pairs.
|
|
287
|
+
func fileDigests(hashes map[string][sha256.Size]byte) []graph.FileDigest {
|
|
288
|
+
out := make([]graph.FileDigest, 0, len(hashes))
|
|
289
|
+
for path, hash := range hashes {
|
|
290
|
+
out = append(out, graph.FileDigest{File: path, Digest: graph.Digest(hash)})
|
|
291
|
+
}
|
|
292
|
+
return out
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// rootFileEntries splits the internal config\x00file root encoding back into the
|
|
296
|
+
// pair it stands for. The joined form exists only so the root set compares with
|
|
297
|
+
// slices.Equal; it is not a shape to publish.
|
|
298
|
+
func rootFileEntries(roots []string) []graph.RootFile {
|
|
299
|
+
out := make([]graph.RootFile, 0, len(roots))
|
|
300
|
+
for _, root := range roots {
|
|
301
|
+
config, file, found := strings.Cut(root, "\x00")
|
|
302
|
+
if !found {
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
out = append(out, graph.RootFile{Config: config, File: file})
|
|
306
|
+
}
|
|
307
|
+
return out
|
|
308
|
+
}
|
|
309
|
+
|
|
198
310
|
// missingRootInputs returns config root files absent from the loaded program.
|
|
199
311
|
// A literal `files` entry keeps its name whether or not the file exists, so
|
|
200
312
|
// neither the root-set comparison nor source hashing notices when such a root
|
|
@@ -214,15 +326,39 @@ func missingRootInputs(configs []*shimtsoptions.ParsedCommandLine, sourceHashes
|
|
|
214
326
|
func (s *graphSession) buildDump() graph.Dump {
|
|
215
327
|
program := s.compiler.Program()
|
|
216
328
|
built := graph.Build(program)
|
|
329
|
+
// One texts map feeds both the spans and the manifest digests, so the bytes a
|
|
330
|
+
// span points into are provably the bytes the manifest attests to.
|
|
331
|
+
texts := graph.SourceTexts(program)
|
|
217
332
|
return graph.NewDump(
|
|
218
333
|
built,
|
|
219
334
|
s.cwd,
|
|
220
335
|
s.tsconfig,
|
|
221
336
|
graph.GitIgnoredFiles(s.cwd, built),
|
|
222
|
-
|
|
337
|
+
texts,
|
|
338
|
+
graph.DumpOrigin{
|
|
339
|
+
Provenance: graph.NewProvenance(
|
|
340
|
+
s.cwd,
|
|
341
|
+
serveProducer(),
|
|
342
|
+
fullSnapshotCapabilities,
|
|
343
|
+
s.configDigests,
|
|
344
|
+
s.roots,
|
|
345
|
+
texts,
|
|
346
|
+
s.diskDigests,
|
|
347
|
+
),
|
|
348
|
+
Diagnostics: graph.NewDiagnostics(program, s.cwd),
|
|
349
|
+
},
|
|
223
350
|
)
|
|
224
351
|
}
|
|
225
352
|
|
|
353
|
+
// serveProducer names this binary and the checker it links.
|
|
354
|
+
func serveProducer() graph.Producer {
|
|
355
|
+
return graph.Producer{
|
|
356
|
+
Tool: "ttscgraph",
|
|
357
|
+
Version: version,
|
|
358
|
+
Typescript: graph.TypescriptVersion(),
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
226
362
|
func configFiles(configs []*shimtsoptions.ParsedCommandLine) []string {
|
|
227
363
|
files := []string{}
|
|
228
364
|
for _, parsed := range configs {
|
|
@@ -311,8 +447,22 @@ func invalidProjectError(diags []driver.Diagnostic) error {
|
|
|
311
447
|
return fmt.Errorf("ttscgraph: invalid project: %s", strings.Join(messages, "; "))
|
|
312
448
|
}
|
|
313
449
|
|
|
314
|
-
|
|
450
|
+
// hashProgramSources returns two maps keyed by absolute source path.
|
|
451
|
+
//
|
|
452
|
+
// The first is the invalidation state: the hash the next snapshot compares
|
|
453
|
+
// against to decide whether a file moved. It is deliberately not always the
|
|
454
|
+
// file's disk hash — a file that raced the load is recorded under its resident
|
|
455
|
+
// text so the comparison is guaranteed to miss and force a revisit.
|
|
456
|
+
//
|
|
457
|
+
// The second is the source manifest's disk evidence: the hex digest of the bytes
|
|
458
|
+
// actually read from disk, present only when the read succeeded. These are
|
|
459
|
+
// separate values because the first is a sentinel chosen to control the next
|
|
460
|
+
// comparison and the second is a fact published to a consumer. Publishing the
|
|
461
|
+
// sentinel would tell a consumer that a file it is about to read hashes to
|
|
462
|
+
// something it can never reproduce.
|
|
463
|
+
func hashProgramSources(program *driver.Program) (map[string][sha256.Size]byte, map[string]string, error) {
|
|
315
464
|
hashes := make(map[string][sha256.Size]byte)
|
|
465
|
+
digests := make(map[string]string)
|
|
316
466
|
for _, source := range program.TSProgram.SourceFiles() {
|
|
317
467
|
// Virtual sources (tsgo's `bundled:///` libs) have no on-disk identity;
|
|
318
468
|
// real project files always carry an absolute path.
|
|
@@ -334,9 +484,13 @@ func hashProgramSources(program *driver.Program) (map[string][sha256.Size]byte,
|
|
|
334
484
|
}
|
|
335
485
|
content, err := os.ReadFile(source.FileName())
|
|
336
486
|
if err != nil {
|
|
337
|
-
return nil, fmt.Errorf("ttscgraph: read %s: %w", source.FileName(), err)
|
|
487
|
+
return nil, nil, fmt.Errorf("ttscgraph: read %s: %w", source.FileName(), err)
|
|
338
488
|
}
|
|
339
489
|
rawHash := sha256.Sum256(content)
|
|
490
|
+
// The bytes were read, so their digest is a fact regardless of whether they
|
|
491
|
+
// match what the checker holds. When they do not, the manifest's text and
|
|
492
|
+
// disk digests disagree, which is precisely what a consumer needs to see.
|
|
493
|
+
digests[source.FileName()] = graph.Digest(rawHash)
|
|
340
494
|
expected := driver.ApplySourcePreambleToFile(source.FileName(), string(content), program.SourcePreamble)
|
|
341
495
|
if source.Text() == expected {
|
|
342
496
|
hashes[source.FileName()] = rawHash
|
|
@@ -346,7 +500,7 @@ func hashProgramSources(program *driver.Program) (map[string][sha256.Size]byte,
|
|
|
346
500
|
hashes[source.FileName()] = sha256.Sum256([]byte(source.Text()))
|
|
347
501
|
}
|
|
348
502
|
}
|
|
349
|
-
return hashes, nil
|
|
503
|
+
return hashes, digests, nil
|
|
350
504
|
}
|
|
351
505
|
|
|
352
506
|
func hashFiles(paths []string) (map[string][sha256.Size]byte, error) {
|
|
@@ -833,22 +987,37 @@ func serveSnapshots(input io.Reader, output io.Writer, cwd, tsconfig string) int
|
|
|
833
987
|
}
|
|
834
988
|
var request serveRequest
|
|
835
989
|
if err := json.Unmarshal([]byte(line), &request); err != nil {
|
|
836
|
-
|
|
837
|
-
|
|
990
|
+
// A response is addressed by id, and an unparseable line has no id to
|
|
991
|
+
// address it to. Replying with the zero id answered nobody: the client
|
|
992
|
+
// drops a frame matching no pending request, so the caller's promise
|
|
993
|
+
// never settled and the graph call hung forever.
|
|
994
|
+
//
|
|
995
|
+
// There is no recoverable reading of this. The client is the only writer
|
|
996
|
+
// and it writes nothing but {"id":N}, so a line it cannot produce means
|
|
997
|
+
// the stream is not the protocol. Fail it: the exit carries this stderr
|
|
998
|
+
// to the client, which rejects every pending request with it — an
|
|
999
|
+
// outcome, where the dropped frame was silence.
|
|
1000
|
+
fmt.Fprintf(stderr, "ttscgraph: unaddressable serve request: %v\n", err)
|
|
1001
|
+
return 1
|
|
838
1002
|
}
|
|
839
1003
|
if session == nil {
|
|
840
1004
|
created, err := newGraphSession(cwd, tsconfig)
|
|
841
1005
|
if err != nil {
|
|
842
|
-
_ = encoder.Encode(
|
|
1006
|
+
_ = encoder.Encode(errorResponse(request.ID, err.Error()))
|
|
843
1007
|
continue
|
|
844
1008
|
}
|
|
845
1009
|
session = created
|
|
846
1010
|
}
|
|
847
1011
|
dump, mode, changed, err := session.Snapshot()
|
|
848
|
-
response :=
|
|
1012
|
+
response := newServeResponse(request.ID)
|
|
1013
|
+
response.Dump = dump
|
|
1014
|
+
response.Mode = mode
|
|
1015
|
+
response.Changed = changed
|
|
849
1016
|
if err != nil {
|
|
850
1017
|
response.Error = err.Error()
|
|
851
1018
|
response.Dump = nil
|
|
1019
|
+
response.Mode = serveModeError
|
|
1020
|
+
response.Changed = false
|
|
852
1021
|
}
|
|
853
1022
|
if err := encoder.Encode(response); err != nil {
|
|
854
1023
|
fmt.Fprintf(stderr, "ttscgraph: write serve response: %v\n", err)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// TestServeErrorResponseStillNamesItsMode verifies a failed snapshot answers
|
|
13
|
+
// with a mode and a protocol version rather than dropping them.
|
|
14
|
+
//
|
|
15
|
+
// This is the negative twin of the successful snapshot. Mode used to be
|
|
16
|
+
// omitempty, so it vanished on exactly the path where a consumer most needs to
|
|
17
|
+
// say what happened: the error path serves no dump and set no mode, leaving
|
|
18
|
+
// "what did the compiler do" unanswerable and the field unrelied-upon. A field
|
|
19
|
+
// that is present only when things go well is not a field a consumer can branch
|
|
20
|
+
// on.
|
|
21
|
+
//
|
|
22
|
+
// 1. Serve a project whose tsconfig is invalid, so the session cannot be built.
|
|
23
|
+
// 2. Assert the response carries the error, and mode is the error mode.
|
|
24
|
+
// 3. Assert the protocol version still rides it, so the client can read it at
|
|
25
|
+
// all.
|
|
26
|
+
func TestServeErrorResponseStillNamesItsMode(t *testing.T) {
|
|
27
|
+
root := graphSessionFixture(t)
|
|
28
|
+
if err := os.WriteFile(filepath.Join(root, "tsconfig.json"), []byte("{ invalid"), 0o644); err != nil {
|
|
29
|
+
t.Fatal(err)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
var output bytes.Buffer
|
|
33
|
+
if code := serveSnapshots(strings.NewReader("{\"id\":7}\n"), &output, root, "tsconfig.json"); code != 0 {
|
|
34
|
+
t.Fatalf("serveSnapshots exited %d", code)
|
|
35
|
+
}
|
|
36
|
+
// Keep the bytes: they are read twice below, once as the typed envelope and
|
|
37
|
+
// once as bare keys, and a decoder would drain the buffer before the second.
|
|
38
|
+
line := output.Bytes()
|
|
39
|
+
var response serveResponse
|
|
40
|
+
if err := json.Unmarshal(line, &response); err != nil {
|
|
41
|
+
t.Fatal(err)
|
|
42
|
+
}
|
|
43
|
+
if response.Error == "" {
|
|
44
|
+
t.Fatalf("invalid config produced no error: %#v", response)
|
|
45
|
+
}
|
|
46
|
+
if response.ID != 7 {
|
|
47
|
+
t.Fatalf("error response id %d, want the request's 7", response.ID)
|
|
48
|
+
}
|
|
49
|
+
if response.Mode != serveModeError {
|
|
50
|
+
t.Fatalf("error response mode %q, want %q", response.Mode, serveModeError)
|
|
51
|
+
}
|
|
52
|
+
if response.ProtocolVersion != serveProtocolVersion {
|
|
53
|
+
t.Fatalf("error response protocol version %d, want %d", response.ProtocolVersion, serveProtocolVersion)
|
|
54
|
+
}
|
|
55
|
+
if response.Dump != nil {
|
|
56
|
+
t.Fatal("error response carried a dump")
|
|
57
|
+
}
|
|
58
|
+
if response.Changed {
|
|
59
|
+
t.Fatal("error response claimed the graph changed")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The wire keys, not the Go fields: mode must survive serialization, which is
|
|
63
|
+
// the whole regression — omitempty dropped it there and nowhere else.
|
|
64
|
+
var raw map[string]any
|
|
65
|
+
if err := json.Unmarshal(line, &raw); err != nil {
|
|
66
|
+
t.Fatal(err)
|
|
67
|
+
}
|
|
68
|
+
for _, key := range []string{"mode", "protocolVersion", "capabilities"} {
|
|
69
|
+
if _, ok := raw[key]; !ok {
|
|
70
|
+
t.Fatalf("error response omitted %q on the wire: %s", key, line)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
package/cmd/ttscgraph/serve_protocol_fails_the_stream_on_an_unaddressable_request_line_test.go
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// TestServeProtocolFailsTheStreamOnAnUnaddressableRequestLine verifies a
|
|
10
|
+
// malformed NDJSON line ends the stream with a diagnostic on stderr instead of
|
|
11
|
+
// answering with a reply nobody can read.
|
|
12
|
+
//
|
|
13
|
+
// The server used to answer an unparseable line with an error carrying the zero
|
|
14
|
+
// ID, because none could be parsed. That reply had no addressee: the launcher
|
|
15
|
+
// matches a response to a pending request by id and drops anything else, and its
|
|
16
|
+
// ids start at 1, so the frame was discarded and the caller's promise never
|
|
17
|
+
// settled — a graph call hung forever on a line the client itself sent. There is
|
|
18
|
+
// no recoverable reading of a line the protocol cannot address, so failing the
|
|
19
|
+
// stream is the honest outcome: the exit carries this stderr to the client,
|
|
20
|
+
// which rejects every pending request with it.
|
|
21
|
+
//
|
|
22
|
+
// 1. Send a non-JSON line, then a valid request that must never be served.
|
|
23
|
+
// 2. Assert serveSnapshots exits non-zero and names the offending line.
|
|
24
|
+
// 3. Assert no response frame was written, since none could be addressed.
|
|
25
|
+
func TestServeProtocolFailsTheStreamOnAnUnaddressableRequestLine(t *testing.T) {
|
|
26
|
+
root := graphSessionFixture(t)
|
|
27
|
+
oldStderr := stderr
|
|
28
|
+
defer func() { stderr = oldStderr }()
|
|
29
|
+
var errOut bytes.Buffer
|
|
30
|
+
stderr = &errOut
|
|
31
|
+
|
|
32
|
+
input := strings.NewReader("not-json\n{\"id\":1}\n")
|
|
33
|
+
var output bytes.Buffer
|
|
34
|
+
if code := serveSnapshots(input, &output, root, "tsconfig.json"); code == 0 {
|
|
35
|
+
t.Fatalf("serveSnapshots accepted an unaddressable line: %q", output.String())
|
|
36
|
+
}
|
|
37
|
+
if !strings.Contains(errOut.String(), "unaddressable serve request") {
|
|
38
|
+
t.Fatalf("stderr did not explain the failure: %q", errOut.String())
|
|
39
|
+
}
|
|
40
|
+
if output.Len() != 0 {
|
|
41
|
+
t.Fatalf("serveSnapshots answered an unaddressable line: %q", output.String())
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"regexp"
|
|
8
|
+
"strconv"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// TestServeProtocolVersionMatchesTheTypescriptClient verifies the Go server and
|
|
13
|
+
// the TypeScript client agree on the serve protocol version.
|
|
14
|
+
//
|
|
15
|
+
// The envelope exists in two hand-written places with no generator between them:
|
|
16
|
+
// serveResponse here and ITtscGraphSnapshot in @ttsc/graph. Nothing but this
|
|
17
|
+
// test makes bumping one a failure until the other follows. That is the whole
|
|
18
|
+
// point of the field — a version both sides invent separately detects nothing —
|
|
19
|
+
// so the drift it is meant to catch must not be able to start here.
|
|
20
|
+
//
|
|
21
|
+
// 1. Read PROTOCOL_VERSION out of the TypeScript client's source.
|
|
22
|
+
// 2. Compare it to serveProtocolVersion.
|
|
23
|
+
func TestServeProtocolVersionMatchesTheTypescriptClient(t *testing.T) {
|
|
24
|
+
client := filepath.Join("..", "..", "..", "graph", "src", "model", "TtscGraphSession.ts")
|
|
25
|
+
source, err := os.ReadFile(client)
|
|
26
|
+
if err != nil {
|
|
27
|
+
t.Fatalf("read the TypeScript client: %v", err)
|
|
28
|
+
}
|
|
29
|
+
// The checkout is CRLF on Windows, and a line-anchored match would never
|
|
30
|
+
// see the end of a line that ends in a carriage return.
|
|
31
|
+
source = bytes.ReplaceAll(source, []byte("\r\n"), []byte("\n"))
|
|
32
|
+
match := regexp.MustCompile(`(?m)^const PROTOCOL_VERSION = (\d+);$`).FindSubmatch(source)
|
|
33
|
+
if match == nil {
|
|
34
|
+
t.Fatalf("no `const PROTOCOL_VERSION = <n>;` in %s; if it moved, this gate must follow it", client)
|
|
35
|
+
}
|
|
36
|
+
declared, err := strconv.Atoi(string(match[1]))
|
|
37
|
+
if err != nil {
|
|
38
|
+
t.Fatalf("PROTOCOL_VERSION is not a number: %v", err)
|
|
39
|
+
}
|
|
40
|
+
if declared != serveProtocolVersion {
|
|
41
|
+
t.Fatalf(
|
|
42
|
+
"serve protocol drifted: Go serves v%d, %s reads v%d",
|
|
43
|
+
serveProtocolVersion,
|
|
44
|
+
client,
|
|
45
|
+
declared,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"os"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
|
|
12
|
+
"github.com/samchon/ttsc/packages/ttsc/internal/graph"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// TestServeSnapshotProvesItsProgramWithoutASecondRead verifies a single serve
|
|
16
|
+
// response carries everything needed to prove which program produced it.
|
|
17
|
+
//
|
|
18
|
+
// This is the contract the envelope exists for. A consumer used to be handed
|
|
19
|
+
// paths and nothing else, so proving that the nodes, edges, and the file bytes
|
|
20
|
+
// it was about to read all belonged to one Program meant re-reading the disk
|
|
21
|
+
// afterwards and asking the server a second time whether anything had moved —
|
|
22
|
+
// which narrows the race without closing it, and never proves the bytes read are
|
|
23
|
+
// the bytes the checker resolved against. Everything below has to come out of
|
|
24
|
+
// one frame, because a second frame is the thing being replaced.
|
|
25
|
+
//
|
|
26
|
+
// 1. Take one snapshot of a fixture project.
|
|
27
|
+
// 2. Assert the envelope names its protocol, mode, and capabilities.
|
|
28
|
+
// 3. Assert every file the dump names carries a digest, and that hashing the
|
|
29
|
+
// file off the disk independently reproduces it.
|
|
30
|
+
func TestServeSnapshotProvesItsProgramWithoutASecondRead(t *testing.T) {
|
|
31
|
+
root := graphSessionFixture(t)
|
|
32
|
+
var output bytes.Buffer
|
|
33
|
+
if code := serveSnapshots(strings.NewReader("{\"id\":1}\n"), &output, root, "tsconfig.json"); code != 0 {
|
|
34
|
+
t.Fatalf("serveSnapshots exited %d", code)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
var response serveResponse
|
|
38
|
+
if err := json.NewDecoder(&output).Decode(&response); err != nil {
|
|
39
|
+
t.Fatal(err)
|
|
40
|
+
}
|
|
41
|
+
if response.Error != "" {
|
|
42
|
+
t.Fatalf("snapshot failed: %s", response.Error)
|
|
43
|
+
}
|
|
44
|
+
if response.ProtocolVersion != serveProtocolVersion {
|
|
45
|
+
t.Fatalf("envelope protocol version %d, want %d", response.ProtocolVersion, serveProtocolVersion)
|
|
46
|
+
}
|
|
47
|
+
if response.Mode != serveModeInitial {
|
|
48
|
+
t.Fatalf("mode %q, want %q", response.Mode, serveModeInitial)
|
|
49
|
+
}
|
|
50
|
+
if len(response.Capabilities) == 0 {
|
|
51
|
+
t.Fatal("envelope declared no capabilities, so a consumer cannot tell what it may rely on")
|
|
52
|
+
}
|
|
53
|
+
if response.Dump == nil {
|
|
54
|
+
t.Fatal("initial snapshot carried no dump")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
provenance := response.Dump.Provenance
|
|
58
|
+
if provenance.SchemaVersion != graph.DumpSchemaVersion {
|
|
59
|
+
t.Fatalf("dump schema version %d, want %d", provenance.SchemaVersion, graph.DumpSchemaVersion)
|
|
60
|
+
}
|
|
61
|
+
if provenance.Producer.Tool != "ttscgraph" {
|
|
62
|
+
t.Fatalf("provenance names producer %q, want ttscgraph", provenance.Producer.Tool)
|
|
63
|
+
}
|
|
64
|
+
if provenance.Producer.Typescript == "" {
|
|
65
|
+
t.Fatal("provenance did not name the TypeScript version behind the facts")
|
|
66
|
+
}
|
|
67
|
+
if len(provenance.Universe.Configs) == 0 {
|
|
68
|
+
t.Fatal("universe fingerprinted no config, though the fixture has a tsconfig")
|
|
69
|
+
}
|
|
70
|
+
if len(provenance.Universe.Roots) == 0 {
|
|
71
|
+
t.Fatal("universe fingerprinted no root file")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Every file a node names must appear in the manifest. A file with facts but
|
|
75
|
+
// no digest is exactly the gap the manifest closes: a consumer would have to
|
|
76
|
+
// trust it.
|
|
77
|
+
digests := make(map[string]graph.SourceDigest, len(provenance.Sources))
|
|
78
|
+
for _, source := range provenance.Sources {
|
|
79
|
+
digests[source.File] = source
|
|
80
|
+
}
|
|
81
|
+
for _, node := range response.Dump.Nodes {
|
|
82
|
+
if node.External {
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if _, ok := digests[node.File]; !ok {
|
|
86
|
+
t.Fatalf("node %q names file %q, which the manifest does not digest", node.ID, node.File)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The point of the disk digest: a consumer that opens the file itself can
|
|
91
|
+
// reproduce it. Do exactly that, from outside the compiler.
|
|
92
|
+
proven := 0
|
|
93
|
+
for file, source := range digests {
|
|
94
|
+
if source.Disk == "" {
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
content, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(file)))
|
|
98
|
+
if err != nil {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
if got := graph.Digest(sha256.Sum256(content)); got != source.Disk {
|
|
102
|
+
t.Fatalf("independent read of %q hashes to %s, manifest says %s", file, got, source.Disk)
|
|
103
|
+
}
|
|
104
|
+
proven++
|
|
105
|
+
}
|
|
106
|
+
if proven == 0 {
|
|
107
|
+
t.Fatal("no file's digest could be reproduced from disk, so the manifest proves nothing")
|
|
108
|
+
}
|
|
109
|
+
}
|