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.
Files changed (37) hide show
  1. package/README.md +68 -186
  2. package/cmd/graphdump/main.go +21 -1
  3. package/cmd/ttsc/api_compile.go +3 -13
  4. package/cmd/ttsc/api_transform.go +13 -0
  5. package/cmd/ttscgraph/dump.go +40 -1
  6. package/cmd/ttscgraph/serve.go +194 -25
  7. package/cmd/ttscgraph/serve_error_response_still_names_its_mode_test.go +73 -0
  8. package/cmd/ttscgraph/serve_protocol_fails_the_stream_on_an_unaddressable_request_line_test.go +43 -0
  9. package/cmd/ttscgraph/serve_protocol_version_matches_the_typescript_client_test.go +48 -0
  10. package/cmd/ttscgraph/serve_snapshot_proves_its_program_without_a_second_read_test.go +109 -0
  11. package/driver/graph.go +133 -0
  12. package/internal/graph/diagnostics.go +6 -5
  13. package/internal/graph/dump.go +69 -17
  14. package/internal/graph/dump_diagnostics_ride_the_generation_that_built_it_test.go +79 -0
  15. package/internal/graph/dump_evidence_skips_leading_trivia_test.go +1 -1
  16. package/internal/graph/dump_is_a_function_of_the_source_test.go +1 -1
  17. package/internal/graph/dump_never_serializes_a_list_as_null_test.go +76 -0
  18. package/internal/graph/dump_schema_version_matches_the_typescript_client_test.go +48 -0
  19. package/internal/graph/dump_serializes_the_full_graph_test.go +21 -7
  20. package/internal/graph/node_modifiers_emit_union_strings_test.go +1 -1
  21. package/internal/graph/provenance.go +312 -0
  22. package/lib/TtscCompiler.js +9 -4
  23. package/lib/TtscCompiler.js.map +1 -1
  24. package/lib/compiler/internal/transformProjectInMemory.d.ts +4 -0
  25. package/lib/compiler/internal/transformProjectInMemory.js +67 -12
  26. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  27. package/lib/structures/ITtscCompilerTransformation.d.ts +98 -0
  28. package/package.json +8 -8
  29. package/shim/compiler/incremental.go +58 -0
  30. package/shim/core/shim.go +5 -0
  31. package/src/TtscCompiler.ts +19 -4
  32. package/src/compiler/internal/transformProjectInMemory.ts +106 -12
  33. package/src/structures/ITtscCompilerTransformation.ts +107 -0
  34. package/tools/shim_audit/baseline.json +2 -1
  35. package/tools/shim_audit/main.go +17 -0
  36. package/utility/host.go +17 -15
  37. package/cmd/ttscgraph/serve_protocol_rejects_invalid_request_line_test.go +0 -43
@@ -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 *graph.Dump `json:"dump,omitempty"`
31
- Error string `json:"error,omitempty"`
32
- ID int `json:"id"`
33
- Mode string `json:"mode,omitempty"`
34
- Changed bool `json:"changed"`
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
- initialized bool
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, "initial", true, nil
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, "reload", true, nil
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, "reload", true, nil
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, "reload", true, nil
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, "reload", true, nil
196
+ return &dump, serveModeReload, true, nil
112
197
  }
113
198
  if len(changed) == 0 {
114
- return nil, "unchanged", false, 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, "reload", true, nil
206
+ return &dump, serveModeReload, true, nil
122
207
  }
123
208
 
124
- mode := "incremental"
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 = "rebuild"
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, "reload", true, nil
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
- graph.SourceTexts(program),
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
- func hashProgramSources(program *driver.Program) (map[string][sha256.Size]byte, error) {
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
- _ = encoder.Encode(serveResponse{Error: fmt.Sprintf("invalid request: %v", err)})
837
- continue
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(serveResponse{ID: request.ID, Error: err.Error()})
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 := serveResponse{ID: request.ID, Dump: dump, Mode: mode, Changed: changed}
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
+ }
@@ -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
+ }