ttsc 0.26.2 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/README.md +9 -0
  2. package/cmd/graphbench/main.go +26 -16
  3. package/cmd/graphdump/main.go +37 -12
  4. package/cmd/graphdump/run_declares_the_capability_its_output_carries_test.go +137 -0
  5. package/cmd/graphdump/run_reports_an_unknown_flag_without_exiting_the_process_test.go +56 -0
  6. package/cmd/graphdump/run_reports_an_unloadable_project_without_printing_a_dump_test.go +46 -0
  7. package/cmd/ttsc/api_transform.go +28 -3
  8. package/cmd/ttscgraph/auxiliary_identity_state_tracks_retargets_not_source_contents_test.go +85 -0
  9. package/cmd/ttscgraph/disk_realpath_other.go +16 -0
  10. package/cmd/ttscgraph/disk_realpath_windows.go +56 -0
  11. package/cmd/ttscgraph/dump.go +43 -11
  12. package/cmd/ttscgraph/run_dump_claims_artifacts_only_when_it_asked_test.go +135 -0
  13. package/cmd/ttscgraph/run_dump_declares_the_capability_its_output_carries_test.go +96 -0
  14. package/cmd/ttscgraph/serve.go +357 -60
  15. package/cmd/ttscgraph/serve_adopts_a_republished_artifact_set_test.go +348 -0
  16. package/cmd/ttscgraph/serve_projects_an_artifact_no_source_owns_test.go +90 -0
  17. package/cmd/ttscgraph/serve_shards.go +31 -9
  18. package/driver/config_discovery.go +121 -0
  19. package/driver/emit_plugin.go +77 -11
  20. package/driver/graph.go +64 -11
  21. package/driver/input_observation.go +273 -0
  22. package/driver/input_observation_test.go +149 -0
  23. package/driver/plugins.go +506 -15
  24. package/driver/program.go +54 -1
  25. package/driver/resolution_candidates.go +13 -5
  26. package/driver/rewrite.go +45 -2
  27. package/driver/transform_dependencies.go +283 -0
  28. package/driver/transform_dependencies_declares_every_file_without_a_contributor_test.go +28 -0
  29. package/driver/transform_dependencies_drops_a_self_reported_dependency_test.go +21 -0
  30. package/driver/transform_dependencies_keys_a_reported_path_like_the_envelope_test.go +46 -0
  31. package/driver/transform_dependencies_requires_every_contributor_to_declare_test.go +38 -0
  32. package/driver/transform_dependencies_stays_silent_for_an_undeclared_contributor_test.go +23 -0
  33. package/driver/transform_dependencies_withdraws_completeness_after_an_unusable_dependency_test.go +37 -0
  34. package/internal/graph/artifacts.go +230 -0
  35. package/internal/graph/artifacts_index_what_a_plugin_published_test.go +154 -0
  36. package/internal/graph/artifacts_survive_a_partial_rebuild_test.go +89 -0
  37. package/internal/graph/build.go +35 -0
  38. package/internal/graph/build_releases_the_scratch_it_no_longer_reads_test.go +118 -0
  39. package/internal/graph/doc_refs_are_the_use_the_checker_already_counts_test.go +93 -0
  40. package/internal/graph/doc_refs_resolve_documentation_links_test.go +335 -0
  41. package/internal/graph/doc_tags.go +416 -0
  42. package/internal/graph/doc_tags_are_claimed_before_they_are_read_test.go +131 -0
  43. package/internal/graph/doc_tags_capture_every_unrecognized_tag_test.go +165 -0
  44. package/internal/graph/doc_tags_read_only_what_the_parser_attached_test.go +97 -0
  45. package/internal/graph/doc_tags_stop_at_the_external_boundary_test.go +59 -0
  46. package/internal/graph/doc_tags_survive_line_endings_and_repeated_blocks_test.go +146 -0
  47. package/internal/graph/dump.go +60 -13
  48. package/internal/graph/dump_checkout_paths_are_stable_test.go +2 -2
  49. package/internal/graph/dump_paths.go +2 -2
  50. package/internal/graph/dump_paths_are_portable_and_injective_test.go +1 -1
  51. package/internal/graph/edge_kinds_names_every_declared_kind_test.go +49 -0
  52. package/internal/graph/edges.go +1 -0
  53. package/internal/graph/graph.go +79 -4
  54. package/internal/graph/provenance.go +29 -2
  55. package/lib/TtscCompiler.d.ts +12 -11
  56. package/lib/TtscCompiler.js +44 -29
  57. package/lib/TtscCompiler.js.map +1 -1
  58. package/lib/compiler/internal/captureProcessOutput.js +2 -2
  59. package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
  60. package/lib/compiler/internal/compileProjectInMemory.js +4 -3
  61. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  62. package/lib/compiler/internal/project/createNativeProjectContextArgs.d.ts +9 -0
  63. package/lib/compiler/internal/project/createNativeProjectContextArgs.js +16 -4
  64. package/lib/compiler/internal/project/createNativeProjectContextArgs.js.map +1 -1
  65. package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -0
  66. package/lib/compiler/internal/resolveEmittedJavaScript.js +3 -0
  67. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  68. package/lib/compiler/internal/runBuild.d.ts +1 -1
  69. package/lib/compiler/internal/runBuild.js +80 -34
  70. package/lib/compiler/internal/runBuild.js.map +1 -1
  71. package/lib/compiler/internal/runSingleFileEmit.js +2 -2
  72. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  73. package/lib/compiler/internal/sharedHostHelpers.d.ts +28 -0
  74. package/lib/compiler/internal/sharedHostHelpers.js +39 -0
  75. package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
  76. package/lib/compiler/internal/startResidentTransform.js +12 -4
  77. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  78. package/lib/compiler/internal/transformProjectInMemory.d.ts +3 -0
  79. package/lib/compiler/internal/transformProjectInMemory.js +239 -35
  80. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  81. package/lib/flags/schema.js +12 -5
  82. package/lib/flags/schema.js.map +1 -1
  83. package/lib/index.d.ts +1 -0
  84. package/lib/index.js +1 -0
  85. package/lib/index.js.map +1 -1
  86. package/lib/internal/createCanonicalTempDirectory.d.ts +17 -0
  87. package/lib/internal/createCanonicalTempDirectory.js +42 -0
  88. package/lib/internal/createCanonicalTempDirectory.js.map +1 -0
  89. package/lib/internal/projectInputPathIdentity.d.ts +3 -0
  90. package/lib/internal/projectInputPathIdentity.js +11 -5
  91. package/lib/internal/projectInputPathIdentity.js.map +1 -1
  92. package/lib/internal/resolveNodeBinary.d.ts +14 -0
  93. package/lib/internal/resolveNodeBinary.js +150 -0
  94. package/lib/internal/resolveNodeBinary.js.map +1 -0
  95. package/lib/internal/resolveSafeCacheCleanupTargets.d.ts +15 -0
  96. package/lib/internal/resolveSafeCacheCleanupTargets.js +61 -0
  97. package/lib/internal/resolveSafeCacheCleanupTargets.js.map +1 -0
  98. package/lib/launcher/internal/prepareExecution.d.ts +3 -0
  99. package/lib/launcher/internal/prepareExecution.js +42 -14
  100. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  101. package/lib/launcher/internal/runTtsc.js +25 -13
  102. package/lib/launcher/internal/runTtsc.js.map +1 -1
  103. package/lib/launcher/internal/runTtscserver.js +18 -3
  104. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  105. package/lib/launcher/internal/runTtsx.js +2 -0
  106. package/lib/launcher/internal/runTtsx.js.map +1 -1
  107. package/lib/launcher/internal/runtimeHooks.d.ts +47 -3
  108. package/lib/launcher/internal/runtimeHooks.js +746 -64
  109. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  110. package/lib/plugin/internal/buildSourcePlugin.d.ts +62 -8
  111. package/lib/plugin/internal/buildSourcePlugin.js +754 -72
  112. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  113. package/lib/plugin/internal/capabilityResolutionCache.d.ts +76 -0
  114. package/lib/plugin/internal/capabilityResolutionCache.js +224 -0
  115. package/lib/plugin/internal/capabilityResolutionCache.js.map +1 -0
  116. package/lib/plugin/internal/loadProjectPlugins.d.ts +17 -5
  117. package/lib/plugin/internal/loadProjectPlugins.js +1339 -77
  118. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  119. package/lib/plugin/resolveCapabilityPlugins.d.ts +55 -0
  120. package/lib/plugin/resolveCapabilityPlugins.js +118 -0
  121. package/lib/plugin/resolveCapabilityPlugins.js.map +1 -0
  122. package/lib/register.d.ts +1 -0
  123. package/lib/register.js +46 -0
  124. package/lib/register.js.map +1 -0
  125. package/lib/structures/ITtscCompilerContext.d.ts +6 -3
  126. package/lib/structures/ITtscCompilerTransformation.d.ts +38 -0
  127. package/lib/structures/ITtscPlugin.d.ts +33 -0
  128. package/package.json +16 -9
  129. package/shim/ast/parent.go +16 -3
  130. package/shim/checker/enums_gen.go +11 -0
  131. package/shim/checker/go.mod +9 -0
  132. package/shim/checker/go.sum +22 -0
  133. package/shim/checker/shim.go +78 -0
  134. package/shim/compiler/emithook.go +10 -0
  135. package/shim/compiler/incremental.go +45 -8
  136. package/src/TtscCompiler.ts +55 -33
  137. package/src/compiler/internal/captureProcessOutput.ts +3 -2
  138. package/src/compiler/internal/compileProjectInMemory.ts +4 -3
  139. package/src/compiler/internal/project/createNativeProjectContextArgs.ts +19 -4
  140. package/src/compiler/internal/resolveEmittedJavaScript.ts +6 -0
  141. package/src/compiler/internal/runBuild.ts +102 -39
  142. package/src/compiler/internal/runSingleFileEmit.ts +2 -2
  143. package/src/compiler/internal/sharedHostHelpers.ts +44 -0
  144. package/src/compiler/internal/startResidentTransform.ts +12 -3
  145. package/src/compiler/internal/transformProjectInMemory.ts +410 -39
  146. package/src/flags/schema.ts +12 -5
  147. package/src/index.ts +1 -0
  148. package/src/internal/createCanonicalTempDirectory.ts +53 -0
  149. package/src/internal/projectInputPathIdentity.ts +17 -4
  150. package/src/internal/resolveNodeBinary.ts +173 -0
  151. package/src/internal/resolveSafeCacheCleanupTargets.ts +85 -0
  152. package/src/launcher/internal/prepareExecution.ts +50 -14
  153. package/src/launcher/internal/runTtsc.ts +24 -13
  154. package/src/launcher/internal/runTtscserver.ts +18 -3
  155. package/src/launcher/internal/runTtsx.ts +2 -0
  156. package/src/launcher/internal/runtimeHooks.ts +934 -68
  157. package/src/plugin/internal/buildSourcePlugin.ts +984 -79
  158. package/src/plugin/internal/capabilityResolutionCache.ts +305 -0
  159. package/src/plugin/internal/loadProjectPlugins.ts +1718 -104
  160. package/src/plugin/resolveCapabilityPlugins.ts +171 -0
  161. package/src/register.ts +47 -0
  162. package/src/structures/ITtscCompilerContext.ts +6 -3
  163. package/src/structures/ITtscCompilerTransformation.ts +46 -0
  164. package/src/structures/ITtscPlugin.ts +36 -0
  165. package/tools/shim_audit/baseline.json +0 -2
  166. package/utility/host.go +18 -3
  167. package/lib/internal/assertSafeExplicitCacheDirectory.d.ts +0 -5
  168. package/lib/internal/assertSafeExplicitCacheDirectory.js +0 -26
  169. package/lib/internal/assertSafeExplicitCacheDirectory.js.map +0 -1
  170. package/src/internal/assertSafeExplicitCacheDirectory.ts +0 -29
@@ -0,0 +1,348 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "slices"
10
+ "strings"
11
+ "testing"
12
+
13
+ "github.com/samchon/ttsc/packages/ttsc/internal/graph"
14
+ )
15
+
16
+ // TestServeAdoptsARepublishedArtifactSet verifies a resident session replaces
17
+ // its artifacts when the client republishes them, and does so without reloading
18
+ // the compiler.
19
+ //
20
+ // The artifacts describe documents the Program never read. That is deliberate —
21
+ // it is what keeps editing a Markdown heading from costing a typecheck — but it
22
+ // also means not one of the session's invalidation inputs moves when the
23
+ // document does. Read once at startup, the set outlived every edit to it: a
24
+ // developer who renamed a section watched the graph keep answering with the name
25
+ // it used to have, for as long as the editor stayed open.
26
+ //
27
+ // The mode is asserted, not merely the change. Reloading the Program would also
28
+ // pick up the new set, and would also report `changed`; it would just pay a full
29
+ // typecheck for a fact the compiler has no opinion about. `rebuild` is the
30
+ // answer that reuses the resident Program and reprojects it, and it is the only
31
+ // one that keeps the property this design exists for.
32
+ //
33
+ // 1. Publish one artifact set and take an initial snapshot naming it.
34
+ // 2. Ask again naming the same file, and require `unchanged`.
35
+ // 3. Publish a different set and ask again naming the new file.
36
+ // 4. Require a `rebuild` carrying the new artifact and not the old one.
37
+ // 5. Name a file that does not exist and require an error, not an empty graph.
38
+ // 6. State the empty path and require the artifacts withdrawn.
39
+ func TestServeAdoptsARepublishedArtifactSet(t *testing.T) {
40
+ root := graphSessionFixture(t)
41
+ published := t.TempDir()
42
+ first := filepath.Join(published, "first.json")
43
+ second := filepath.Join(published, "second.json")
44
+ writeArtifactSet(t, first, "docs/sale.md#pricing", "Pricing")
45
+ writeArtifactSet(t, second, "docs/sale.md#discounts", "Discounts")
46
+
47
+ var output bytes.Buffer
48
+ code := serveSnapshotsWithArtifacts(
49
+ bytes.NewReader([]byte(fmt.Sprintf(
50
+ "{\"id\":1,\"artifacts\":%s}\n{\"id\":2,\"artifacts\":%s}\n{\"id\":3,\"artifacts\":%s}\n{\"id\":4,\"artifacts\":%s}\n{\"id\":5,\"artifacts\":%s}\n",
51
+ mustJSONString(t, first),
52
+ mustJSONString(t, first),
53
+ mustJSONString(t, second),
54
+ mustJSONString(t, filepath.Join(published, "never-written.json")),
55
+ `""`,
56
+ ))),
57
+ &output,
58
+ root,
59
+ "tsconfig.json",
60
+ nil,
61
+ )
62
+ if code != 0 {
63
+ t.Fatalf("serveSnapshotsWithArtifacts exited %d: %s", code, output.String())
64
+ }
65
+
66
+ decoder := json.NewDecoder(&output)
67
+ responses := make([]serveResponse, 5)
68
+ for index := range responses {
69
+ if err := decoder.Decode(&responses[index]); err != nil {
70
+ t.Fatalf("response %d: %v", index+1, err)
71
+ }
72
+ }
73
+ initial, repeated, republished := responses[0], responses[1], responses[2]
74
+ missing, withdrawn := responses[3], responses[4]
75
+
76
+ if initial.Mode != serveModeInitial || !initial.Changed || initial.Dump == nil {
77
+ t.Fatalf("initial response: %#v", initial)
78
+ }
79
+ if !dumpCarriesArtifact(initial.Dump, "docs/sale.md#pricing") {
80
+ t.Fatal("the initial dump does not carry the artifact its request named")
81
+ }
82
+
83
+ // Naming the same file again must cost nothing. The client states the path on
84
+ // every request because only it can see the inputs behind the set; a server
85
+ // that treated the statement itself as news would reproject the whole graph
86
+ // on every single call.
87
+ if repeated.Mode != serveModeUnchanged || repeated.Changed || repeated.Dump != nil {
88
+ t.Fatalf("restating the same artifact file was treated as a change: %#v", repeated)
89
+ }
90
+
91
+ if republished.Mode != serveModeRebuild {
92
+ t.Fatalf(
93
+ "a republished artifact set answered %q; %q reuses the resident program, and no compiler input moved",
94
+ republished.Mode,
95
+ serveModeRebuild,
96
+ )
97
+ }
98
+ if !republished.Changed || republished.Dump == nil {
99
+ t.Fatalf("republished response carried no graph: %#v", republished)
100
+ }
101
+ if !dumpCarriesArtifact(republished.Dump, "docs/sale.md#discounts") {
102
+ t.Fatal("the republished dump does not carry the artifact that replaced the old one")
103
+ }
104
+ if dumpCarriesArtifact(republished.Dump, "docs/sale.md#pricing") {
105
+ t.Fatal("the republished dump still carries the withdrawn artifact")
106
+ }
107
+
108
+ // A named file that is not there is a broken exchange, not a project without
109
+ // artifacts. Reading it as the latter empties the overlay and answers with a
110
+ // graph indistinguishable from a correct one for a project that publishes
111
+ // none — the one failure this whole exchange has no other way to catch.
112
+ if missing.Mode != serveModeError || missing.Error == "" {
113
+ t.Fatalf("a named artifact file that does not exist answered %#v", missing)
114
+ }
115
+ if missing.Dump != nil || missing.Changed {
116
+ t.Fatalf("an error response carried snapshot state: %#v", missing)
117
+ }
118
+
119
+ // The empty path is how a client says it now publishes none — the state a
120
+ // project reaches by removing its plugin. Without it the only sayable things
121
+ // are "here is a set" and "no opinion", and the removal would go on being
122
+ // answered with the artifacts of a plugin that is gone.
123
+ if withdrawn.Mode != serveModeRebuild || !withdrawn.Changed {
124
+ t.Fatalf("withdrawing the artifacts answered %#v", withdrawn)
125
+ }
126
+ if dumpCarriesArtifact(withdrawn.Dump, "docs/sale.md#discounts") {
127
+ t.Fatal("a withdrawn artifact is still in the graph")
128
+ }
129
+ if slices.Contains(withdrawn.Capabilities, string(graph.CapabilityArtifactNodes)) {
130
+ t.Fatalf("a session holding no artifacts still claims them: %v", withdrawn.Capabilities)
131
+ }
132
+ }
133
+
134
+ // writeArtifactSet publishes a one-entry set in the shape the client writes.
135
+ func writeArtifactSet(t *testing.T, file, address, readable string) {
136
+ t.Helper()
137
+ contents, err := json.Marshal([]map[string]any{{
138
+ "address": address,
139
+ "kind": "markdown_section",
140
+ "readable": readable,
141
+ "file": "docs/sale.md",
142
+ "line": 7,
143
+ }})
144
+ if err != nil {
145
+ t.Fatal(err)
146
+ }
147
+ if err := os.WriteFile(file, contents, 0o644); err != nil {
148
+ t.Fatal(err)
149
+ }
150
+ }
151
+
152
+ // mustJSONString quotes a path for the request line, which matters on Windows
153
+ // where a temporary directory is full of separators JSON reads as escapes.
154
+ func mustJSONString(t *testing.T, value string) string {
155
+ t.Helper()
156
+ encoded, err := json.Marshal(value)
157
+ if err != nil {
158
+ t.Fatal(err)
159
+ }
160
+ return string(encoded)
161
+ }
162
+
163
+ func dumpCarriesArtifact(dump any, address string) bool {
164
+ encoded, err := json.Marshal(dump)
165
+ if err != nil {
166
+ return false
167
+ }
168
+ var decoded struct {
169
+ Nodes []struct {
170
+ ID string `json:"id"`
171
+ } `json:"nodes"`
172
+ }
173
+ if err := json.Unmarshal(encoded, &decoded); err != nil {
174
+ return false
175
+ }
176
+ for _, node := range decoded.Nodes {
177
+ if node.ID == address {
178
+ return true
179
+ }
180
+ }
181
+ return false
182
+ }
183
+
184
+ // TestServeKeepsAStartupSetAClientNeverMentions verifies that a request saying
185
+ // nothing about artifacts leaves the ones the session started with alone.
186
+ //
187
+ // Three clients reach this path and none of them names a file: one built before
188
+ // the field existed, one driving `ttscgraph serve --artifacts` by hand, and one
189
+ // whose project publishes nothing but whose session was handed a set through
190
+ // the flag. Reading their silence as "I have none" would empty the graph for
191
+ // all three — and it is the same code path that must read an explicit empty
192
+ // statement as exactly that, which is why the two are a pointer apart rather
193
+ // than an empty string apart.
194
+ //
195
+ // 1. Start a session with an artifact set through the flag.
196
+ // 2. Send two requests carrying no artifacts field at all.
197
+ // 3. Require the set to survive both, and the capability to stay claimed.
198
+ func TestServeKeepsAStartupSetAClientNeverMentions(t *testing.T) {
199
+ root := graphSessionFixture(t)
200
+
201
+ var output bytes.Buffer
202
+ code := serveSnapshotsWithArtifacts(
203
+ bytes.NewReader([]byte("{\"id\":1}\n{\"id\":2}\n")),
204
+ &output,
205
+ root,
206
+ "tsconfig.json",
207
+ []graph.Artifact{{
208
+ Address: "docs/sale.md#pricing",
209
+ File: "docs/sale.md",
210
+ Kind: "markdown_section",
211
+ Line: 7,
212
+ Readable: "Pricing",
213
+ }},
214
+ )
215
+ if code != 0 {
216
+ t.Fatalf("serveSnapshotsWithArtifacts exited %d: %s", code, output.String())
217
+ }
218
+
219
+ decoder := json.NewDecoder(&output)
220
+ var initial serveResponse
221
+ var second serveResponse
222
+ if err := decoder.Decode(&initial); err != nil {
223
+ t.Fatal(err)
224
+ }
225
+ if err := decoder.Decode(&second); err != nil {
226
+ t.Fatal(err)
227
+ }
228
+ if !dumpCarriesArtifact(initial.Dump, "docs/sale.md#pricing") {
229
+ t.Fatalf("the startup set is absent from the initial dump: %#v", initial)
230
+ }
231
+ // The second request is where a silence-means-withdrawal reading would show:
232
+ // the first is the initial projection and carries the set either way.
233
+ if second.Mode != serveModeUnchanged || second.Changed {
234
+ t.Fatalf("a request naming no artifacts was treated as a change: %#v", second)
235
+ }
236
+ for _, response := range []serveResponse{initial, second} {
237
+ if !slices.Contains(response.Capabilities, string(graph.CapabilityArtifactNodes)) {
238
+ t.Fatalf("a session holding the startup set stopped claiming it: %v", response.Capabilities)
239
+ }
240
+ }
241
+ }
242
+
243
+ // TestServeAdoptsARepublishedSetOverTheShardProtocol verifies the refresh
244
+ // reaches the protocol the product actually speaks.
245
+ //
246
+ // `@ttsc/graph` negotiates incremental shards on every request, so the
247
+ // full-dump response the other cases drive is the compatibility path and not
248
+ // the one a running editor uses. The two share one invalidation decision and
249
+ // then diverge completely — different projection, different snapshot shape,
250
+ // different assembly of what a client ends up holding — so a refresh proven on
251
+ // one is not proven on the other.
252
+ //
253
+ // 1. Negotiate shards and take an initial snapshot naming one artifact set.
254
+ // 2. Name a different set on the next request.
255
+ // 3. Require a full shard snapshot carrying the new artifact and not the old.
256
+ func TestServeAdoptsARepublishedSetOverTheShardProtocol(t *testing.T) {
257
+ root := t.TempDir()
258
+ writeGraphFile(t, filepath.Join(root, "tsconfig.json"), `{
259
+ "compilerOptions": { "target": "ES2022", "module": "commonjs", "strict": true },
260
+ "include": ["src"]
261
+ }`)
262
+ // The declaration cites the artifact, so the edge to it lives in this
263
+ // source's own shard while the artifact node lands in the metadata shard.
264
+ // Both have to move together: a snapshot that replaced the artifact and left
265
+ // this shard alone would leave the client holding an edge into an address
266
+ // nothing publishes any more.
267
+ writeGraphFile(t, filepath.Join(root, "src", "index.ts"),
268
+ "/** @evidence docs/sale.md#pricing States the rule. */\nexport class Priced {}\n")
269
+ published := t.TempDir()
270
+ first := filepath.Join(published, "first.json")
271
+ second := filepath.Join(published, "second.json")
272
+ writeArtifactSet(t, first, "docs/sale.md#pricing", "Pricing")
273
+ writeArtifactSet(t, second, "docs/sale.md#discounts", "Discounts")
274
+
275
+ var output bytes.Buffer
276
+ code := serveSnapshotsWithArtifacts(
277
+ bytes.NewReader([]byte(fmt.Sprintf(
278
+ "{\"id\":1,\"graphSnapshotVersion\":%d,\"artifacts\":%s}\n{\"id\":2,\"graphSnapshotVersion\":%d,\"artifacts\":%s}\n",
279
+ graphSnapshotProtocolVersion,
280
+ mustJSONString(t, first),
281
+ graphSnapshotProtocolVersion,
282
+ mustJSONString(t, second),
283
+ ))),
284
+ &output,
285
+ root,
286
+ "tsconfig.json",
287
+ nil,
288
+ )
289
+ if code != 0 {
290
+ t.Fatalf("serveSnapshotsWithArtifacts exited %d: %s", code, output.String())
291
+ }
292
+
293
+ decoder := json.NewDecoder(&output)
294
+ var initial serveResponse
295
+ var republished serveResponse
296
+ if err := decoder.Decode(&initial); err != nil {
297
+ t.Fatal(err)
298
+ }
299
+ if err := decoder.Decode(&republished); err != nil {
300
+ t.Fatal(err)
301
+ }
302
+ if initial.Snapshot == nil {
303
+ t.Fatalf("the shard protocol was not negotiated: %#v", initial)
304
+ }
305
+ if !shardsCarryArtifact(initial.Snapshot, "docs/sale.md#pricing") {
306
+ t.Fatal("the initial shard snapshot does not carry the artifact its request named")
307
+ }
308
+ if republished.Mode != serveModeRebuild || republished.Snapshot == nil {
309
+ t.Fatalf("a republished set over the shard protocol answered %#v", republished)
310
+ }
311
+ if !shardsCarryArtifact(republished.Snapshot, "docs/sale.md#discounts") {
312
+ t.Fatal("the republished shard snapshot does not carry the artifact that replaced the old one")
313
+ }
314
+ if shardsCarryArtifact(republished.Snapshot, "docs/sale.md#pricing") {
315
+ t.Fatal("the republished shard snapshot still carries the withdrawn artifact")
316
+ }
317
+ // The citing source's shard has to come with it, whichever projection the
318
+ // session chose. Its edges name the address that just moved, so a client left
319
+ // holding the previous version of this shard holds a dangling one.
320
+ if !shardsCarrySource(republished.Snapshot, "src/index.ts") {
321
+ t.Fatal("the shard owning the citing declaration was not re-emitted, so its edge to the withdrawn address survives in the client")
322
+ }
323
+ }
324
+
325
+ // shardsCarrySource reports whether a shard for the named source was upserted.
326
+ func shardsCarrySource(snapshot *serveGraphSnapshot, suffix string) bool {
327
+ for _, upsert := range snapshot.Upserts {
328
+ if upsert.Shard.Source == nil {
329
+ continue
330
+ }
331
+ if strings.HasSuffix(upsert.Shard.Source.File, suffix) {
332
+ return true
333
+ }
334
+ }
335
+ return false
336
+ }
337
+
338
+ // shardsCarryArtifact reports whether any upserted shard holds the address.
339
+ func shardsCarryArtifact(snapshot *serveGraphSnapshot, address string) bool {
340
+ for _, upsert := range snapshot.Upserts {
341
+ for _, node := range upsert.Shard.Nodes {
342
+ if node.ID == address {
343
+ return true
344
+ }
345
+ }
346
+ }
347
+ return false
348
+ }
@@ -0,0 +1,90 @@
1
+ package main
2
+
3
+ import (
4
+ "path/filepath"
5
+ "slices"
6
+ "testing"
7
+
8
+ "github.com/samchon/ttsc/packages/ttsc/internal/graph"
9
+ )
10
+
11
+ // TestServeProjectsAnArtifactNoSourceOwns verifies that a session carrying
12
+ // published artifacts can produce a shard snapshot at all.
13
+ //
14
+ // The shard projection assigns every node to the source that owns it, and fails
15
+ // closed when a node names a file the manifest does not carry — a guard that is
16
+ // right for a declaration, whose file is always a program source. A published
17
+ // artifact has no such file: it is a Markdown document, a Prisma schema, or, for
18
+ // an operation named by method and path, nothing at all. So the guard rejected
19
+ // it and the resident session failed to start for any project that publishes
20
+ // one, which is the whole product surface.
21
+ //
22
+ // The metadata shard is where facts no source owns belong, and the client
23
+ // exempts it from the ownership check for that reason.
24
+ //
25
+ // 1. Build a session over a one-file project, carrying one artifact.
26
+ // 2. Take a full shard snapshot.
27
+ // 3. Assert it succeeded and that the artifact is in it.
28
+ func TestServeProjectsAnArtifactNoSourceOwns(t *testing.T) {
29
+ root := t.TempDir()
30
+ writeGraphFile(t, filepath.Join(root, "tsconfig.json"), `{
31
+ "compilerOptions": { "target": "ES2022", "module": "commonjs", "strict": true },
32
+ "files": ["src/main.ts"]
33
+ }
34
+ `)
35
+ writeGraphFile(t, filepath.Join(root, "src", "main.ts"), `/** @evidence docs/sale.md#pricing States the rule. */
36
+ export function priced(): void {}
37
+ `)
38
+
39
+ session, err := newGraphSessionWithArtifacts(root, "tsconfig.json", []graph.Artifact{
40
+ {
41
+ Address: "docs/sale.md#pricing",
42
+ Kind: "markdown_section",
43
+ Readable: "Pricing",
44
+ File: "docs/sale.md",
45
+ Line: 7,
46
+ },
47
+ {
48
+ // No file at all, which is the shape that has no source to be owned by
49
+ // even in principle.
50
+ Address: "POST:/orders",
51
+ Kind: "swagger_operation",
52
+ Readable: "POST /orders",
53
+ },
54
+ })
55
+ if err != nil {
56
+ t.Fatalf("the session refused to start with artifacts: %v", err)
57
+ }
58
+ defer func() { _ = session.Close() }()
59
+
60
+ snapshot, _, err := session.buildFullShardSnapshot()
61
+ if err != nil {
62
+ t.Fatalf("the shard projection rejected a published artifact: %v", err)
63
+ }
64
+
65
+ published := map[string]bool{}
66
+ for _, upsert := range snapshot.Upserts {
67
+ for _, node := range upsert.Shard.Nodes {
68
+ published[node.ID] = true
69
+ if upsert.Shard.Source != nil && node.File != upsert.Shard.Source.File {
70
+ t.Fatalf("shard %s owns node %s from %s", upsert.Shard.Key, node.ID, node.File)
71
+ }
72
+ }
73
+ }
74
+ for _, address := range []string{"docs/sale.md#pricing", "POST:/orders"} {
75
+ if !published[address] {
76
+ t.Fatalf("the snapshot carries no node for %q", address)
77
+ }
78
+ }
79
+
80
+ // The session's own claim, which is what the envelope answers with. An
81
+ // `unchanged` response carries no dump, so the envelope is the only place a
82
+ // client can learn this server holds artifacts; a shared constant there made
83
+ // the envelope and the dump disagree on exactly that frame.
84
+ if !slices.Contains(session.capabilities(), graph.CapabilityArtifactNodes) {
85
+ t.Fatalf("a session holding artifacts declares %v", session.capabilities())
86
+ }
87
+ if session.artifactProducer() == nil {
88
+ t.Fatal("a session holding artifacts named no second producer")
89
+ }
90
+ }
@@ -139,15 +139,9 @@ func (s *graphSession) buildShardSnapshot(change *graphChange) (*serveGraphSnaps
139
139
  func (s *graphSession) buildFullShardSnapshot() (*serveGraphSnapshot, *serveGraphStore, error) {
140
140
  program := s.compiler.Program()
141
141
  built := graph.Build(program)
142
+ graph.ApplyArtifacts(built, s.artifacts)
142
143
  texts := graph.SourceTexts(program)
143
- provenance := graph.NewProvenance(
144
- serveProducer(),
145
- fullSnapshotCapabilities,
146
- s.configDigests,
147
- s.roots,
148
- texts,
149
- s.diskDigests,
150
- )
144
+ provenance := s.provenance(texts)
151
145
  identity, wireProvenance, wireSources, err := newServeGraphIdentity(s.cwd, s.tsconfig, provenance)
152
146
  if err != nil {
153
147
  return nil, nil, err
@@ -268,6 +262,15 @@ func (s *graphSession) buildIncrementalShardSnapshot(change *graphChange) (*serv
268
262
  expandPriorImplementationSources(selected, prior)
269
263
  }
270
264
 
265
+ // The artifacts belong to every generation, not only the full one. A partial
266
+ // build re-resolves the outgoing facts of the files it selected, and a
267
+ // citation of an artifact is one of them: without this, editing a declaration
268
+ // that cites a document section rebuilds it with that edge missing, and the
269
+ // client's graph loses a relation the full snapshot had. The nodes are added
270
+ // again because a partial starts with only its selected files' nodes; they are
271
+ // the same nodes under the same ids, so the store replaces like with like.
272
+ graph.ApplyArtifacts(partial, s.artifacts)
273
+
271
274
  projectionFiles := maps.Clone(selected)
272
275
  for _, node := range partial.Nodes {
273
276
  projectionFiles[node.File] = true
@@ -733,8 +736,14 @@ func validateServeGraphShardContents(key string, shard serveGraphShard) error {
733
736
  }
734
737
  return nil
735
738
  }
739
+ // The metadata shard carries the nodes no program source owns: external
740
+ // boundary leaves, and published artifacts. An artifact is not authored code
741
+ // and has no source to be owned by — a Markdown document, a Prisma schema, or
742
+ // nothing at all for an operation named by method and path — so the guard that
743
+ // keeps authored declarations out of this shard has to name it explicitly
744
+ // rather than treat "not external" as "authored".
736
745
  for _, node := range shard.Nodes {
737
- if !node.External {
746
+ if !node.External && !graph.IsArtifactKind(graph.NodeKind(node.Kind)) {
738
747
  return fmt.Errorf("ttscgraph: metadata shard %s owns authored node %s", key, node.ID)
739
748
  }
740
749
  }
@@ -859,6 +868,19 @@ func partitionServeGraphFacts(
859
868
  externalNodes[node.ID] = node
860
869
  continue
861
870
  }
871
+ // A published artifact is not owned by a program source: its file is a
872
+ // Markdown document, a Prisma schema, or nothing at all for an operation
873
+ // named only by method and path. It belongs to the metadata shard, which
874
+ // carries the facts no source owns — and which the client exempts from the
875
+ // ownership check for exactly that reason. Without this the projection
876
+ // rejected the node outright and the resident session failed to start for
877
+ // any project that publishes one.
878
+ if graph.IsArtifactKind(graph.NodeKind(node.Kind)) {
879
+ metadata := shards[metadataKey]
880
+ metadata.Nodes = append(metadata.Nodes, node)
881
+ shards[metadataKey] = metadata
882
+ continue
883
+ }
862
884
  physical, ok := relativeToPhysical[node.File]
863
885
  if !ok {
864
886
  return nil, nil, nil, nil, nil, fmt.Errorf("ttscgraph: node source is absent from shard manifest: %s", node.File)
@@ -0,0 +1,121 @@
1
+ package driver
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ )
7
+
8
+ // ConfigDiscovery is the result of one upward config-file search: what it
9
+ // found, and what it looked at on the way.
10
+ type ConfigDiscovery struct {
11
+ // Directory is the directory the search stopped in, empty when nothing
12
+ // matched anywhere up to the filesystem root.
13
+ Directory string
14
+ // Matches are the config files present in Directory, in the caller's name
15
+ // order. More than one is the ambiguity each plugin reports in its own
16
+ // words; none means the search reached the root.
17
+ Matches []string
18
+ // Probed are the candidates the search examined and rejected, in every
19
+ // directory it visited up to and including Directory.
20
+ //
21
+ // These are the paths that can supersede the result: a file created at any
22
+ // of them either wins the search outright, because it sits nearer the entry
23
+ // than the match, or makes the matching directory ambiguous. A persistent
24
+ // consumer that never hears about them keeps serving output built from a
25
+ // config a cold run would no longer choose, which is why a plugin reports
26
+ // them as host inputs rather than dropping them.
27
+ //
28
+ // A rejected candidate is usually absent, but a directory carrying a config
29
+ // file's name is rejected too, and the two are not the same observation: the
30
+ // host-input contract records an existing directory by its directory-kind
31
+ // digest and its physical path, so that replacing it with a file invalidates
32
+ // the generation. Reporting one as absent instead makes every consumer's
33
+ // check disagree with its own filesystem forever.
34
+ Probed []ConfigCandidate
35
+ }
36
+
37
+ // ConfigCandidate is one path a config search rejected, and what it found
38
+ // there.
39
+ type ConfigCandidate struct {
40
+ // Directory reports that the path exists and is a directory.
41
+ Directory bool
42
+ // Path is the candidate's absolute location.
43
+ Path string
44
+ }
45
+
46
+ // DiscoverConfigFile walks upward from base looking for any of names in each
47
+ // directory, stopping at the first directory that contains at least one.
48
+ //
49
+ // The walk is the one every first-party utility plugin runs for its
50
+ // `<plugin>.config.*` file. It is shared here so the set of superseding
51
+ // candidates is derived by the same rule everywhere, since that set is the part
52
+ // a consumer needs and the part each plugin was most likely to leave out.
53
+ func DiscoverConfigFile(base string, names []string) ConfigDiscovery {
54
+ out := ConfigDiscovery{}
55
+ directory := base
56
+ for {
57
+ matches := make([]string, 0, 1)
58
+ probed := make([]ConfigCandidate, 0, len(names))
59
+ for _, name := range names {
60
+ candidate := filepath.Join(directory, name)
61
+ stat, err := os.Stat(candidate)
62
+ switch {
63
+ case err == nil && !stat.IsDir():
64
+ matches = append(matches, candidate)
65
+ case err == nil:
66
+ probed = append(probed, ConfigCandidate{Directory: true, Path: candidate})
67
+ default:
68
+ probed = append(probed, ConfigCandidate{Path: candidate})
69
+ }
70
+ }
71
+ out.Probed = append(out.Probed, probed...)
72
+ if len(matches) != 0 {
73
+ out.Directory = directory
74
+ out.Matches = matches
75
+ return out
76
+ }
77
+ parent := filepath.Dir(directory)
78
+ if parent == directory {
79
+ return out
80
+ }
81
+ directory = parent
82
+ }
83
+ }
84
+
85
+ // ReportRejectedConfigCandidates declares every candidate a config search
86
+ // rejected, so a consumer invalidates when one of them becomes the answer.
87
+ //
88
+ // Each candidate is reported in the state it was found in, because the
89
+ // host-input contract distinguishes them. An absent path takes the paired nil
90
+ // hash and nil realpath, which records the absence as observed state rather
91
+ // than as an unknown and is what lets a persistent adapter keep its narrow
92
+ // reuse instead of declining it. A directory takes the directory-kind digest
93
+ // and its own physical path, the same shape the compiler's own resolution
94
+ // probes record, so that replacing it with a real config file invalidates the
95
+ // generation. Reporting a directory as absent instead leaves every consumer
96
+ // comparing nil against a digest its filesystem keeps producing, and the
97
+ // generation is refused on every delivery for the rest of its life.
98
+ //
99
+ // Takes the two reporters rather than a PluginContext so a plugin that already
100
+ // threads them through its config loader can call it there, which is where the
101
+ // discovery result lives.
102
+ func ReportRejectedConfigCandidates(candidates []ConfigCandidate, hashReporter, realpathReporter func(string, *string)) {
103
+ for _, candidate := range candidates {
104
+ var hash *string
105
+ var realpath *string
106
+ if candidate.Directory {
107
+ digest := observedDirectoryDigest
108
+ hash = &digest
109
+ if resolved, err := filepath.Abs(candidate.Path); err == nil {
110
+ cleaned := filepath.Clean(resolved)
111
+ realpath = &cleaned
112
+ }
113
+ }
114
+ if hashReporter != nil {
115
+ hashReporter(candidate.Path, hash)
116
+ }
117
+ if realpathReporter != nil {
118
+ realpathReporter(candidate.Path, realpath)
119
+ }
120
+ }
121
+ }