ttsc 0.32.1 → 0.33.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 (111) hide show
  1. package/cmd/ttsc-cache/account.go +94 -0
  2. package/cmd/ttsc-cache/interruption_test.go +176 -0
  3. package/cmd/ttsc-cache/main.go +252 -0
  4. package/cmd/ttsc-cache/merge.go +324 -0
  5. package/cmd/ttsc-cache/merge_test.go +161 -0
  6. package/cmd/ttsc-cache/prepare.go +330 -0
  7. package/cmd/ttsc-cache/prepare_test.go +456 -0
  8. package/cmd/ttsc-cache/protocol_test.go +119 -0
  9. package/cmd/ttsc-cache/publisher_seal_test.go +135 -0
  10. package/cmd/ttsc-cache/seal.go +427 -0
  11. package/cmd/ttsc-cache/stock_go_failure_test.go +94 -0
  12. package/cmd/ttsc-cache/stock_go_test.go +163 -0
  13. package/cmd/ttsc-cache/store.go +249 -0
  14. package/cmd/ttsc-cache/store_test.go +104 -0
  15. package/cmd/ttsc-cache/tuple.go +107 -0
  16. package/cmd/ttsc-cache/tuple_test.go +62 -0
  17. package/cmd/ttsc-cache/usage.go +73 -0
  18. package/driver/configloaderscratch/canonical_directory_other.go +34 -0
  19. package/driver/configloaderscratch/canonical_directory_windows.go +60 -0
  20. package/driver/configloaderscratch/identity_temp_base.go +81 -0
  21. package/driver/configloaderscratch/identity_temp_base_posix_test.go +124 -0
  22. package/driver/configloaderscratch/identity_temp_base_windows_test.go +156 -0
  23. package/driver/emit_provenance.go +7 -5
  24. package/driver/resolutioninputs/recorder.cjs +16 -4
  25. package/driver/rewrite.go +25 -16
  26. package/driver/write_file_if_changed.go +69 -0
  27. package/driver/write_file_if_changed_test.go +148 -0
  28. package/internal/packageartifacts/manifest.go +288 -0
  29. package/internal/packageartifacts/manifest_test.go +127 -0
  30. package/lib/compiler/internal/build/BuildExecution.d.ts +11 -7
  31. package/lib/compiler/internal/build/BuildExecution.js +22 -8
  32. package/lib/compiler/internal/build/BuildExecution.js.map +1 -1
  33. package/lib/compiler/internal/build/ResidentCheckWatchSession.d.ts +10 -10
  34. package/lib/compiler/internal/build/ResidentCheckWatchSession.js +19 -14
  35. package/lib/compiler/internal/build/ResidentCheckWatchSession.js.map +1 -1
  36. package/lib/compiler/internal/build/RunBuildOptions.d.ts +8 -1
  37. package/lib/compiler/internal/build/TsgoArguments.d.ts +16 -0
  38. package/lib/compiler/internal/build/TsgoArguments.js +19 -0
  39. package/lib/compiler/internal/build/TsgoArguments.js.map +1 -1
  40. package/lib/compiler/internal/captureProcessOutput.d.ts +16 -13
  41. package/lib/compiler/internal/captureProcessOutput.js +22 -14
  42. package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
  43. package/lib/compiler/internal/startResidentTransform.js +3 -1
  44. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  45. package/lib/compiler/internal/transformProjectInMemory.js +5 -1
  46. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  47. package/lib/launcher/internal/runtime/PluginDescriptorInputReport.d.ts +43 -0
  48. package/lib/launcher/internal/runtime/PluginDescriptorInputReport.js +106 -0
  49. package/lib/launcher/internal/runtime/PluginDescriptorInputReport.js.map +1 -0
  50. package/lib/launcher/internal/runtime/installRuntimeHooks.js +15 -29
  51. package/lib/launcher/internal/runtime/installRuntimeHooks.js.map +1 -1
  52. package/lib/launcher/internal/ttscserver/runTtscserver.js +1 -0
  53. package/lib/launcher/internal/ttscserver/runTtscserver.js.map +1 -1
  54. package/lib/launcher/internal/watch/WatchTopology.js +5 -4
  55. package/lib/launcher/internal/watch/WatchTopology.js.map +1 -1
  56. package/lib/plugin/internal/CapabilityResolutionFormat.js +4 -3
  57. package/lib/plugin/internal/CapabilityResolutionFormat.js.map +1 -1
  58. package/lib/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.js +6 -0
  59. package/lib/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.js.map +1 -1
  60. package/lib/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.js +6 -0
  61. package/lib/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.js.map +1 -1
  62. package/lib/plugin/internal/load/PluginDescriptorEvaluationCache.js +3 -3
  63. package/lib/plugin/internal/load/PluginDescriptorEvaluationCache.js.map +1 -1
  64. package/lib/plugin/internal/load/descriptorRecordEnvironment.d.ts +15 -0
  65. package/lib/plugin/internal/load/descriptorRecordEnvironment.js +24 -0
  66. package/lib/plugin/internal/load/descriptorRecordEnvironment.js.map +1 -0
  67. package/lib/plugin/internal/load/loadProjectPlugins.js +28 -26
  68. package/lib/plugin/internal/load/loadProjectPlugins.js.map +1 -1
  69. package/lib/plugin/internal/source/SourceBuildCacheLayout.d.ts +2 -0
  70. package/lib/plugin/internal/source/SourceBuildCacheLayout.js +2 -0
  71. package/lib/plugin/internal/source/SourceBuildCacheLayout.js.map +1 -1
  72. package/lib/plugin/internal/source/SourcePackageArtifacts.d.ts +145 -0
  73. package/lib/plugin/internal/source/SourcePackageArtifacts.js +612 -0
  74. package/lib/plugin/internal/source/SourcePackageArtifacts.js.map +1 -0
  75. package/lib/plugin/internal/source/buildSourcePlugin.js +54 -4
  76. package/lib/plugin/internal/source/buildSourcePlugin.js.map +1 -1
  77. package/lib/plugin/internal/source/prunePackageArtifactRoot.d.ts +19 -0
  78. package/lib/plugin/internal/source/prunePackageArtifactRoot.js +95 -0
  79. package/lib/plugin/internal/source/prunePackageArtifactRoot.js.map +1 -0
  80. package/lib/plugin/internal/source/resolveCleanTargets.js +1 -1
  81. package/lib/plugin/internal/source/resolveCleanTargets.js.map +1 -1
  82. package/lib/plugin/internal/source/resolveSourceBuildCachePaths.js +9 -4
  83. package/lib/plugin/internal/source/resolveSourceBuildCachePaths.js.map +1 -1
  84. package/lib/plugin/internal/source/selectedRecordIdentityTempParent.d.ts +18 -0
  85. package/lib/plugin/internal/source/selectedRecordIdentityTempParent.js +46 -0
  86. package/lib/plugin/internal/source/selectedRecordIdentityTempParent.js.map +1 -0
  87. package/package.json +8 -8
  88. package/src/compiler/internal/build/BuildExecution.ts +30 -10
  89. package/src/compiler/internal/build/ResidentCheckWatchSession.ts +23 -22
  90. package/src/compiler/internal/build/RunBuildOptions.ts +9 -1
  91. package/src/compiler/internal/build/TsgoArguments.ts +22 -0
  92. package/src/compiler/internal/captureProcessOutput.ts +28 -14
  93. package/src/compiler/internal/startResidentTransform.ts +7 -1
  94. package/src/compiler/internal/transformProjectInMemory.ts +9 -1
  95. package/src/launcher/internal/runtime/PluginDescriptorInputReport.ts +100 -0
  96. package/src/launcher/internal/runtime/installRuntimeHooks.ts +57 -55
  97. package/src/launcher/internal/ttscserver/runTtscserver.ts +5 -0
  98. package/src/launcher/internal/watch/WatchTopology.ts +5 -4
  99. package/src/plugin/internal/CapabilityResolutionFormat.ts +4 -3
  100. package/src/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.ts +4 -0
  101. package/src/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.ts +4 -0
  102. package/src/plugin/internal/load/PluginDescriptorEvaluationCache.ts +3 -3
  103. package/src/plugin/internal/load/descriptorRecordEnvironment.ts +24 -0
  104. package/src/plugin/internal/load/loadProjectPlugins.ts +45 -29
  105. package/src/plugin/internal/source/SourceBuildCacheLayout.ts +3 -0
  106. package/src/plugin/internal/source/SourcePackageArtifacts.ts +787 -0
  107. package/src/plugin/internal/source/buildSourcePlugin.ts +82 -12
  108. package/src/plugin/internal/source/prunePackageArtifactRoot.ts +100 -0
  109. package/src/plugin/internal/source/resolveCleanTargets.ts +1 -0
  110. package/src/plugin/internal/source/resolveSourceBuildCachePaths.ts +11 -4
  111. package/src/plugin/internal/source/selectedRecordIdentityTempParent.ts +40 -0
@@ -0,0 +1,324 @@
1
+ package main
2
+
3
+ import (
4
+ "archive/zip"
5
+ "compress/flate"
6
+ "crypto/sha256"
7
+ "encoding/hex"
8
+ "encoding/json"
9
+ "errors"
10
+ "fmt"
11
+ "io"
12
+ "os"
13
+ "path/filepath"
14
+ "reflect"
15
+ "sort"
16
+ "strings"
17
+
18
+ "github.com/samchon/ttsc/packages/ttsc/internal/packageartifacts"
19
+ )
20
+
21
+ type mergePlan struct {
22
+ Schema int `json:"schema"`
23
+ Inputs []struct {
24
+ Name string `json:"name"`
25
+ Manifest string `json:"manifest"`
26
+ SHA256 string `json:"sha256"`
27
+ } `json:"inputs"`
28
+ PackURLBase string `json:"packURLBase"`
29
+ OutputDir string `json:"outputDir"`
30
+ }
31
+
32
+ type mergeAction struct {
33
+ action packageartifacts.Action
34
+ members uint8
35
+ }
36
+
37
+ type mergeBody struct {
38
+ length int64
39
+ members uint8
40
+ source *zip.File
41
+ }
42
+
43
+ // MergeRecordedPacks makes a deduplicated package-action union from separately
44
+ // sealed, digest-pinned publisher builds. Equal ActionIDs must retain their
45
+ // complete mapping and package evidence; each OutputID is written to one membership
46
+ // pack. The final manifest digest still needs a trusted release metadata pin.
47
+ //
48
+ // @evidence contracts/common.md#principled-implementation Every input manifest and transport ZIP is verified against the operator's separate digest before equal Go actions are joined; conflicting mappings or package evidence fail, while path-dependent command diagnostics use a deterministic representative.
49
+ // @evidence contracts/common.md#clear-and-simple-design One release plan names the approved publisher builds, output directory and immutable URL base; action membership determines reusable common and composition-specific packs.
50
+ // @evidence contracts/common.md#prohibited-implementation-shortcuts Go's full recorded ActionIDs and OutputIDs are copied, never recomputed from package names or an aggregate composition digest.
51
+ // @evidence contracts/common.md#meaningful-documentation The comment distinguishes a verified input union from the later release trust pin needed by consumers.
52
+ // @evidence contracts/portability.md#os-neutral-implementation Native absolute paths and standard ZIP APIs read publisher files; transport entry names and pack names are platform neutral.
53
+ // @evidence contracts/performance.md#efficient-algorithms Maps deduplicate actions and outputs once, and each selected body is streamed to exactly one sorted output ZIP without holding decoded archives in memory.
54
+ // @evidenceExclude contracts/performance.md#reuse-equivalent-work Release publication is a one-shot explicit operation; a changed plan or input is verified afresh.
55
+ // @evidence contracts/performance.md#bound-retention-and-release-resources The plan and manifest have byte/count caps, input archives close after merging, staging ZIPs are removed on failure and output packs retain only verified immutable bytes.
56
+ func MergeRecordedPacks(planPath string) error {
57
+ if !filepath.IsAbs(planPath) {
58
+ return errors.New("merge plan must be absolute")
59
+ }
60
+ raw, err := readBoundedFile(planPath, 64<<10)
61
+ if err != nil {
62
+ return err
63
+ }
64
+ var plan mergePlan
65
+ decoder := json.NewDecoder(strings.NewReader(string(raw)))
66
+ decoder.DisallowUnknownFields()
67
+ if err := decoder.Decode(&plan); err != nil {
68
+ return fmt.Errorf("decode release merge plan: %w", err)
69
+ }
70
+ var trailing any
71
+ if err := decoder.Decode(&trailing); err != io.EOF {
72
+ return errors.New("release merge plan has trailing data")
73
+ }
74
+ if plan.Schema != 1 || len(plan.Inputs) < 2 || len(plan.Inputs) > 8 || !filepath.IsAbs(plan.OutputDir) || plan.PackURLBase == "" {
75
+ return errors.New("release merge plan has unsupported inputs or output")
76
+ }
77
+ var openFiles []*os.File
78
+ defer func() {
79
+ for _, file := range openFiles {
80
+ file.Close()
81
+ }
82
+ }()
83
+ seenNames := make(map[string]bool)
84
+ actions := make(map[string]mergeAction)
85
+ bodies := make(map[string]mergeBody)
86
+ var first packageartifacts.Manifest
87
+ var graphDigests []string
88
+ for index, input := range plan.Inputs {
89
+ if !validPackName(input.Name) || input.Name == "common" || seenNames[input.Name] || !filepath.IsAbs(input.Manifest) || !validID(input.SHA256) {
90
+ return errors.New("release merge input has an unsafe name, path or digest")
91
+ }
92
+ seenNames[input.Name] = true
93
+ manifestRaw, err := readBoundedFile(input.Manifest, packageartifacts.MaxManifestBytes)
94
+ if err != nil {
95
+ return err
96
+ }
97
+ manifest, err := packageartifacts.ParseTrustedManifest(manifestRaw, input.SHA256)
98
+ if err != nil {
99
+ return fmt.Errorf("merge input %s: %w", input.Name, err)
100
+ }
101
+ if index == 0 {
102
+ first = manifest
103
+ } else if manifest.Release != first.Release || manifest.SourceCommit != first.SourceCommit || !reflect.DeepEqual(manifest.Tuple, first.Tuple) {
104
+ return fmt.Errorf("merge input %s belongs to another release or Go tuple", input.Name)
105
+ }
106
+ graphDigests = append(graphDigests, manifest.GraphSHA256)
107
+ packBodies := make(map[string]map[string]int64)
108
+ for _, action := range manifest.Actions {
109
+ if packBodies[action.Pack] == nil {
110
+ packBodies[action.Pack] = make(map[string]int64)
111
+ }
112
+ packBodies[action.Pack][action.OutputID] = action.BodyLength
113
+ }
114
+ packEntries := make(map[string]map[string]*zip.File)
115
+ for _, pack := range manifest.Packs {
116
+ filename := filepath.Join(filepath.Dir(input.Manifest), pack.Name+"-"+pack.SHA256+".zip")
117
+ actual, err := hashFile(filename, packageartifacts.MaxPackBytes)
118
+ if err != nil || actual.digest != pack.SHA256 || actual.length != pack.Length {
119
+ return fmt.Errorf("merge input %s has a missing or corrupt pack %s", input.Name, pack.Name)
120
+ }
121
+ file, err := os.Open(filename)
122
+ if err != nil {
123
+ return err
124
+ }
125
+ openFiles = append(openFiles, file)
126
+ reader, err := zip.NewReader(file, pack.Length)
127
+ if err != nil || len(reader.File) != pack.FileCount {
128
+ return fmt.Errorf("merge input %s has an invalid ZIP catalog", input.Name)
129
+ }
130
+ entries := make(map[string]*zip.File, len(reader.File))
131
+ for _, entry := range reader.File {
132
+ outputID := strings.TrimPrefix(entry.Name, "bodies/")
133
+ length, exists := packBodies[pack.Name][outputID]
134
+ if !strings.HasPrefix(entry.Name, "bodies/") || !exists || entries[outputID] != nil || entry.UncompressedSize64 != uint64(length) {
135
+ return fmt.Errorf("merge input %s has an unexpected ZIP body", input.Name)
136
+ }
137
+ entries[outputID] = entry
138
+ }
139
+ packEntries[pack.Name] = entries
140
+ }
141
+ member := uint8(1 << index)
142
+ for _, action := range manifest.Actions {
143
+ sourcePack := action.Pack
144
+ previous, exists := actions[action.ActionID]
145
+ if exists {
146
+ action.Pack = previous.action.Pack
147
+ commandDigest := action.CommandSHA256
148
+ action.CommandSHA256 = previous.action.CommandSHA256
149
+ if !reflect.DeepEqual(previous.action, action) {
150
+ return fmt.Errorf("conflicting publisher action %s", action.ActionID)
151
+ }
152
+ if commandDigest < previous.action.CommandSHA256 {
153
+ previous.action.CommandSHA256 = commandDigest
154
+ }
155
+ previous.members |= member
156
+ actions[action.ActionID] = previous
157
+ } else {
158
+ actions[action.ActionID] = mergeAction{action: action, members: member}
159
+ }
160
+ body := bodies[action.OutputID]
161
+ if body.source != nil && body.length != action.BodyLength {
162
+ return fmt.Errorf("conflicting publisher body %s", action.OutputID)
163
+ }
164
+ body.length = action.BodyLength
165
+ body.members |= member
166
+ if body.source == nil {
167
+ body.source = packEntries[sourcePack][action.OutputID]
168
+ }
169
+ if body.source == nil {
170
+ return fmt.Errorf("publisher body %s has no ZIP entry", action.OutputID)
171
+ }
172
+ bodies[action.OutputID] = body
173
+ }
174
+ }
175
+ if len(actions) > 100000 || len(bodies) > 100000 {
176
+ return errors.New("merged release exceeds action policy")
177
+ }
178
+ if err := os.MkdirAll(plan.OutputDir, 0700); err != nil {
179
+ return err
180
+ }
181
+ groups := make(map[uint8][]string)
182
+ for outputID, body := range bodies {
183
+ groups[body.members] = append(groups[body.members], outputID)
184
+ }
185
+ if len(groups) > 64 {
186
+ return errors.New("merged release exceeds pack policy")
187
+ }
188
+ manifest := packageartifacts.Manifest{
189
+ Schema: 1, Release: first.Release, SourceCommit: first.SourceCommit, Tuple: first.Tuple,
190
+ GraphSHA256: digestBytes([]byte(strings.Join(graphDigests, "\n"))),
191
+ }
192
+ groupNames := make(map[uint8]string)
193
+ usedNames := make(map[string]bool)
194
+ for members, ids := range groups {
195
+ sort.Strings(ids)
196
+ var names []string
197
+ for index, input := range plan.Inputs {
198
+ if members&(1<<index) != 0 {
199
+ names = append(names, input.Name)
200
+ }
201
+ }
202
+ name := "common"
203
+ if len(names) == 1 {
204
+ name = names[0]
205
+ } else if len(names) != len(plan.Inputs) {
206
+ name = "shared-" + digestBytes([]byte(strings.Join(names, ",")))[:16]
207
+ }
208
+ if usedNames[name] {
209
+ return fmt.Errorf("merged pack name %s collides", name)
210
+ }
211
+ usedNames[name] = true
212
+ groupNames[members] = name
213
+ pack, err := writeMergedPack(plan.OutputDir, plan.PackURLBase, name, ids, bodies)
214
+ if err != nil {
215
+ return err
216
+ }
217
+ manifest.Packs = append(manifest.Packs, pack)
218
+ }
219
+ for _, value := range actions {
220
+ action := value.action
221
+ action.Pack = groupNames[bodies[action.OutputID].members]
222
+ manifest.Actions = append(manifest.Actions, action)
223
+ }
224
+ sort.Slice(manifest.Packs, func(i, j int) bool { return manifest.Packs[i].Name < manifest.Packs[j].Name })
225
+ sort.Slice(manifest.Actions, func(i, j int) bool { return manifest.Actions[i].ActionID < manifest.Actions[j].ActionID })
226
+ manifestRaw, err := json.MarshalIndent(manifest, "", " ")
227
+ if err != nil {
228
+ return err
229
+ }
230
+ manifestRaw = append(manifestRaw, '\n')
231
+ digest := digestBytes(manifestRaw)
232
+ if _, err := packageartifacts.ParseTrustedManifest(manifestRaw, digest); err != nil {
233
+ return err
234
+ }
235
+ manifestFile, err := os.OpenFile(filepath.Join(plan.OutputDir, "manifest.json"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
236
+ if err != nil {
237
+ return err
238
+ }
239
+ _, writeErr := manifestFile.Write(manifestRaw)
240
+ syncErr := manifestFile.Sync()
241
+ closeErr := manifestFile.Close()
242
+ if writeErr != nil || syncErr != nil || closeErr != nil {
243
+ return errors.Join(writeErr, syncErr, closeErr)
244
+ }
245
+ return json.NewEncoder(os.Stdout).Encode(struct {
246
+ ManifestSHA256 string `json:"manifestSHA256"`
247
+ Actions int `json:"actions"`
248
+ Packs int `json:"packs"`
249
+ }{digest, len(manifest.Actions), len(manifest.Packs)})
250
+ }
251
+
252
+ func writeMergedPack(directory, urlBase, name string, ids []string, bodies map[string]mergeBody) (packageartifacts.Pack, error) {
253
+ var pack packageartifacts.Pack
254
+ stage, err := os.CreateTemp(directory, ".ttsc-merge-*.zip")
255
+ if err != nil {
256
+ return pack, err
257
+ }
258
+ defer os.Remove(stage.Name())
259
+ writer := zip.NewWriter(stage)
260
+ writer.RegisterCompressor(zip.Deflate, func(destination io.Writer) (io.WriteCloser, error) {
261
+ return flate.NewWriter(destination, flate.BestSpeed)
262
+ })
263
+ var decoded int64
264
+ for _, outputID := range ids {
265
+ body := bodies[outputID]
266
+ if body.length > packageartifacts.MaxPackDecodedBytes-decoded {
267
+ writer.Close()
268
+ stage.Close()
269
+ return pack, errors.New("merged pack exceeds decoded byte policy")
270
+ }
271
+ input, err := body.source.Open()
272
+ if err != nil {
273
+ writer.Close()
274
+ stage.Close()
275
+ return pack, err
276
+ }
277
+ entry, err := writer.Create("bodies/" + outputID)
278
+ if err != nil {
279
+ input.Close()
280
+ writer.Close()
281
+ stage.Close()
282
+ return pack, err
283
+ }
284
+ hash := sha256.New()
285
+ count, copyErr := io.Copy(io.MultiWriter(entry, hash), io.LimitReader(input, body.length+1))
286
+ closeErr := input.Close()
287
+ if copyErr != nil || closeErr != nil || count != body.length || hex.EncodeToString(hash.Sum(nil)) != outputID {
288
+ writer.Close()
289
+ stage.Close()
290
+ return pack, fmt.Errorf("merged body %s failed verification: %w", outputID, errors.Join(copyErr, closeErr))
291
+ }
292
+ decoded += count
293
+ }
294
+ if err := writer.Close(); err != nil {
295
+ stage.Close()
296
+ return pack, err
297
+ }
298
+ if err := stage.Sync(); err != nil {
299
+ stage.Close()
300
+ return pack, err
301
+ }
302
+ if err := stage.Close(); err != nil {
303
+ return pack, err
304
+ }
305
+ digest, err := hashFile(stage.Name(), packageartifacts.MaxPackBytes)
306
+ if err != nil {
307
+ return pack, err
308
+ }
309
+ filename := name + "-" + digest.digest + ".zip"
310
+ final := filepath.Join(directory, filename)
311
+ if err := os.Link(stage.Name(), final); err != nil {
312
+ if !errors.Is(err, os.ErrExist) {
313
+ return pack, err
314
+ }
315
+ previous, hashErr := hashFile(final, packageartifacts.MaxPackBytes)
316
+ if hashErr != nil || previous != digest {
317
+ return pack, errors.New("existing merged pack differs from verified bytes")
318
+ }
319
+ }
320
+ return packageartifacts.Pack{
321
+ Name: name, URL: strings.TrimRight(urlBase, "/") + "/" + filename,
322
+ SHA256: digest.digest, Length: digest.length, DecodedBytes: decoded, FileCount: len(ids),
323
+ }, nil
324
+ }
@@ -0,0 +1,161 @@
1
+ package main
2
+
3
+ import (
4
+ "archive/zip"
5
+ "bytes"
6
+ "crypto/sha256"
7
+ "encoding/hex"
8
+ "encoding/json"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "testing"
13
+
14
+ "github.com/samchon/ttsc/packages/ttsc/internal/packageartifacts"
15
+ )
16
+
17
+ // TestMergeRecordedPacks checks that a release union retains complete action
18
+ // mappings while placing shared bytes in one pack and rejecting a conflict.
19
+ //
20
+ // Verifies: two authenticated publisher catalogs with one common action yield
21
+ // three packs and one common body, despite workspace-specific command hashes.
22
+ // A conflicting OutputID for the same ActionID stops the union.
23
+ // 1. Write two publisher manifests and packs with one shared action.
24
+ // 2. Merge twice and check pack membership, action mapping and identical output.
25
+ // 3. Change the shared action body and require the merge to reject the conflict.
26
+ //
27
+ // @evidence contracts/testing.md#behavioral-verification Calls the production merger on separately serialized manifests and ZIPs, then parses the produced catalog with its reported digest.
28
+ // @evidence contracts/testing.md#independent-expectations Literal body bytes define SHA-256 output identities independently of merge output; the expected three pack memberships follow from the input overlap.
29
+ // @evidence contracts/testing.md#distinguishing-cases A shared action with differing command diagnostics succeeds, whereas an equal ActionID with a different body fails.
30
+ // @evidence contracts/testing.md#execution-ownership All publisher inputs and merged output reside under t.TempDir and need no external server, installation, or sibling repository.
31
+ func TestMergeRecordedPacks(t *testing.T) {
32
+ root := t.TempDir()
33
+ hash := func(value []byte) string {
34
+ sum := sha256.Sum256(value)
35
+ return hex.EncodeToString(sum[:])
36
+ }
37
+ common := []byte("common archive")
38
+ alpha := []byte("alpha archive")
39
+ beta := []byte("beta archive")
40
+ makeAction := func(key, body, command string) packageartifacts.Action {
41
+ return packageartifacts.Action{
42
+ ActionID: strings.Repeat(key, 64), OutputID: hash([]byte(body)),
43
+ BodySHA256: hash([]byte(body)), BodyLength: int64(len(body)),
44
+ Kind: "archive", PackagePath: "example.org/" + key,
45
+ ModulePath: "example.org/fixture", ModuleRole: "main",
46
+ SelectedSourceSHA: strings.Repeat("d", 64), CommandSHA256: strings.Repeat(command, 64),
47
+ DirectInputs: []string{},
48
+ }
49
+ }
50
+ writeInput := func(name string, sharedCommand string, own []byte, conflict bool) (string, string) {
51
+ t.Helper()
52
+ directory := filepath.Join(root, name)
53
+ if err := os.Mkdir(directory, 0700); err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ shared := makeAction("a", string(common), sharedCommand)
57
+ if conflict {
58
+ shared = makeAction("a", "conflicting archive", sharedCommand)
59
+ }
60
+ uniqueKey := "b"
61
+ if strings.HasPrefix(name, "beta") {
62
+ uniqueKey = "c"
63
+ }
64
+ unique := makeAction(uniqueKey, string(own), "e")
65
+ shared.Pack, unique.Pack = name, name
66
+ var archive bytes.Buffer
67
+ writer := zip.NewWriter(&archive)
68
+ sharedBody := common
69
+ if conflict {
70
+ sharedBody = []byte("conflicting archive")
71
+ }
72
+ for _, body := range [][]byte{sharedBody, own} {
73
+ entry, err := writer.Create("bodies/" + hash(body))
74
+ if err != nil {
75
+ t.Fatal(err)
76
+ }
77
+ if _, err := entry.Write(body); err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ }
81
+ if err := writer.Close(); err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ packHash := hash(archive.Bytes())
85
+ if err := os.WriteFile(filepath.Join(directory, name+"-"+packHash+".zip"), archive.Bytes(), 0600); err != nil {
86
+ t.Fatal(err)
87
+ }
88
+ manifest := packageartifacts.Manifest{
89
+ Schema: 1, Release: "1.0.0", SourceCommit: strings.Repeat("f", 40),
90
+ GraphSHA256: strings.Repeat("0", 64),
91
+ Tuple: packageartifacts.Tuple{
92
+ GoVersion: "go1.26.0", GoToolSHA256: strings.Repeat("1", 64),
93
+ GOOS: "windows", GOARCH: "amd64", CGOEnabled: "0",
94
+ BuildFlags: []string{"-trimpath"}, ToolIDsSHA256: strings.Repeat("2", 64),
95
+ },
96
+ Packs: []packageartifacts.Pack{{
97
+ Name: name, URL: "http://127.0.0.1:1/" + name, SHA256: packHash,
98
+ Length: int64(archive.Len()), DecodedBytes: int64(len(sharedBody) + len(own)), FileCount: 2,
99
+ }},
100
+ Actions: []packageartifacts.Action{shared, unique},
101
+ }
102
+ raw, err := json.Marshal(manifest)
103
+ if err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ path := filepath.Join(directory, "manifest.json")
107
+ if err := os.WriteFile(path, raw, 0600); err != nil {
108
+ t.Fatal(err)
109
+ }
110
+ return path, hash(raw)
111
+ }
112
+ firstPath, firstHash := writeInput("alpha", "3", alpha, false)
113
+ secondPath, secondHash := writeInput("beta", "4", beta, false)
114
+ writePlan := func(output, otherPath, otherHash string) string {
115
+ t.Helper()
116
+ plan := mergePlan{Schema: 1, PackURLBase: "http://127.0.0.1:1/release", OutputDir: filepath.Join(root, output)}
117
+ plan.Inputs = make([]struct {
118
+ Name string `json:"name"`
119
+ Manifest string `json:"manifest"`
120
+ SHA256 string `json:"sha256"`
121
+ }, 2)
122
+ plan.Inputs[0].Name, plan.Inputs[0].Manifest, plan.Inputs[0].SHA256 = "alpha", firstPath, firstHash
123
+ plan.Inputs[1].Name, plan.Inputs[1].Manifest, plan.Inputs[1].SHA256 = "beta", otherPath, otherHash
124
+ raw, err := json.Marshal(plan)
125
+ if err != nil {
126
+ t.Fatal(err)
127
+ }
128
+ path := filepath.Join(root, output+"-plan.json")
129
+ if err := os.WriteFile(path, raw, 0600); err != nil {
130
+ t.Fatal(err)
131
+ }
132
+ return path
133
+ }
134
+ if err := MergeRecordedPacks(writePlan("merged", secondPath, secondHash)); err != nil {
135
+ t.Fatal(err)
136
+ }
137
+ raw, err := os.ReadFile(filepath.Join(root, "merged", "manifest.json"))
138
+ if err != nil {
139
+ t.Fatal(err)
140
+ }
141
+ merged, err := packageartifacts.ParseTrustedManifest(raw, hash(raw))
142
+ if err != nil || len(merged.Packs) != 3 || len(merged.Actions) != 3 {
143
+ t.Fatalf("invalid union: packs=%d actions=%d err=%v", len(merged.Packs), len(merged.Actions), err)
144
+ }
145
+ if err := MergeRecordedPacks(writePlan("repeated", secondPath, secondHash)); err != nil {
146
+ t.Fatal(err)
147
+ }
148
+ repeated, err := os.ReadFile(filepath.Join(root, "repeated", "manifest.json"))
149
+ if err != nil || !bytes.Equal(raw, repeated) {
150
+ t.Fatalf("equivalent release plans differed: %v", err)
151
+ }
152
+ for _, action := range merged.Actions {
153
+ if action.ActionID == strings.Repeat("a", 64) && (action.Pack != "common" || action.CommandSHA256 != strings.Repeat("3", 64)) {
154
+ t.Fatalf("common action lost deterministic evidence or membership: %+v", action)
155
+ }
156
+ }
157
+ conflictPath, conflictHash := writeInput("beta-conflict", "4", beta, true)
158
+ if err := MergeRecordedPacks(writePlan("rejected", conflictPath, conflictHash)); err == nil || !strings.Contains(err.Error(), "conflicting publisher action") {
159
+ t.Fatalf("conflicting full action was accepted: %v", err)
160
+ }
161
+ }