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.
- package/README.md +9 -0
- package/cmd/graphbench/main.go +26 -16
- package/cmd/graphdump/main.go +37 -12
- package/cmd/graphdump/run_declares_the_capability_its_output_carries_test.go +137 -0
- package/cmd/graphdump/run_reports_an_unknown_flag_without_exiting_the_process_test.go +56 -0
- package/cmd/graphdump/run_reports_an_unloadable_project_without_printing_a_dump_test.go +46 -0
- package/cmd/ttsc/api_transform.go +28 -3
- package/cmd/ttscgraph/auxiliary_identity_state_tracks_retargets_not_source_contents_test.go +85 -0
- package/cmd/ttscgraph/disk_realpath_other.go +16 -0
- package/cmd/ttscgraph/disk_realpath_windows.go +56 -0
- package/cmd/ttscgraph/dump.go +43 -11
- package/cmd/ttscgraph/run_dump_claims_artifacts_only_when_it_asked_test.go +135 -0
- package/cmd/ttscgraph/run_dump_declares_the_capability_its_output_carries_test.go +96 -0
- package/cmd/ttscgraph/serve.go +357 -60
- package/cmd/ttscgraph/serve_adopts_a_republished_artifact_set_test.go +348 -0
- package/cmd/ttscgraph/serve_projects_an_artifact_no_source_owns_test.go +90 -0
- package/cmd/ttscgraph/serve_shards.go +31 -9
- package/driver/config_discovery.go +121 -0
- package/driver/emit_plugin.go +77 -11
- package/driver/graph.go +64 -11
- package/driver/input_observation.go +273 -0
- package/driver/input_observation_test.go +149 -0
- package/driver/plugins.go +506 -15
- package/driver/program.go +54 -1
- package/driver/resolution_candidates.go +13 -5
- package/driver/rewrite.go +45 -2
- package/driver/transform_dependencies.go +283 -0
- package/driver/transform_dependencies_declares_every_file_without_a_contributor_test.go +28 -0
- package/driver/transform_dependencies_drops_a_self_reported_dependency_test.go +21 -0
- package/driver/transform_dependencies_keys_a_reported_path_like_the_envelope_test.go +46 -0
- package/driver/transform_dependencies_requires_every_contributor_to_declare_test.go +38 -0
- package/driver/transform_dependencies_stays_silent_for_an_undeclared_contributor_test.go +23 -0
- package/driver/transform_dependencies_withdraws_completeness_after_an_unusable_dependency_test.go +37 -0
- package/internal/graph/artifacts.go +230 -0
- package/internal/graph/artifacts_index_what_a_plugin_published_test.go +154 -0
- package/internal/graph/artifacts_survive_a_partial_rebuild_test.go +89 -0
- package/internal/graph/build.go +35 -0
- package/internal/graph/build_releases_the_scratch_it_no_longer_reads_test.go +118 -0
- package/internal/graph/doc_refs_are_the_use_the_checker_already_counts_test.go +93 -0
- package/internal/graph/doc_refs_resolve_documentation_links_test.go +335 -0
- package/internal/graph/doc_tags.go +416 -0
- package/internal/graph/doc_tags_are_claimed_before_they_are_read_test.go +131 -0
- package/internal/graph/doc_tags_capture_every_unrecognized_tag_test.go +165 -0
- package/internal/graph/doc_tags_read_only_what_the_parser_attached_test.go +97 -0
- package/internal/graph/doc_tags_stop_at_the_external_boundary_test.go +59 -0
- package/internal/graph/doc_tags_survive_line_endings_and_repeated_blocks_test.go +146 -0
- package/internal/graph/dump.go +60 -13
- package/internal/graph/dump_checkout_paths_are_stable_test.go +2 -2
- package/internal/graph/dump_paths.go +2 -2
- package/internal/graph/dump_paths_are_portable_and_injective_test.go +1 -1
- package/internal/graph/edge_kinds_names_every_declared_kind_test.go +49 -0
- package/internal/graph/edges.go +1 -0
- package/internal/graph/graph.go +79 -4
- package/internal/graph/provenance.go +29 -2
- package/lib/TtscCompiler.d.ts +12 -11
- package/lib/TtscCompiler.js +44 -29
- package/lib/TtscCompiler.js.map +1 -1
- package/lib/compiler/internal/captureProcessOutput.js +2 -2
- package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
- package/lib/compiler/internal/compileProjectInMemory.js +4 -3
- package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
- package/lib/compiler/internal/project/createNativeProjectContextArgs.d.ts +9 -0
- package/lib/compiler/internal/project/createNativeProjectContextArgs.js +16 -4
- package/lib/compiler/internal/project/createNativeProjectContextArgs.js.map +1 -1
- package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -0
- package/lib/compiler/internal/resolveEmittedJavaScript.js +3 -0
- package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
- package/lib/compiler/internal/runBuild.d.ts +1 -1
- package/lib/compiler/internal/runBuild.js +80 -34
- package/lib/compiler/internal/runBuild.js.map +1 -1
- package/lib/compiler/internal/runSingleFileEmit.js +2 -2
- package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
- package/lib/compiler/internal/sharedHostHelpers.d.ts +28 -0
- package/lib/compiler/internal/sharedHostHelpers.js +39 -0
- package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
- package/lib/compiler/internal/startResidentTransform.js +12 -4
- package/lib/compiler/internal/startResidentTransform.js.map +1 -1
- package/lib/compiler/internal/transformProjectInMemory.d.ts +3 -0
- package/lib/compiler/internal/transformProjectInMemory.js +239 -35
- package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
- package/lib/flags/schema.js +12 -5
- package/lib/flags/schema.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/internal/createCanonicalTempDirectory.d.ts +17 -0
- package/lib/internal/createCanonicalTempDirectory.js +42 -0
- package/lib/internal/createCanonicalTempDirectory.js.map +1 -0
- package/lib/internal/projectInputPathIdentity.d.ts +3 -0
- package/lib/internal/projectInputPathIdentity.js +11 -5
- package/lib/internal/projectInputPathIdentity.js.map +1 -1
- package/lib/internal/resolveNodeBinary.d.ts +14 -0
- package/lib/internal/resolveNodeBinary.js +150 -0
- package/lib/internal/resolveNodeBinary.js.map +1 -0
- package/lib/internal/resolveSafeCacheCleanupTargets.d.ts +15 -0
- package/lib/internal/resolveSafeCacheCleanupTargets.js +61 -0
- package/lib/internal/resolveSafeCacheCleanupTargets.js.map +1 -0
- package/lib/launcher/internal/prepareExecution.d.ts +3 -0
- package/lib/launcher/internal/prepareExecution.js +42 -14
- package/lib/launcher/internal/prepareExecution.js.map +1 -1
- package/lib/launcher/internal/runTtsc.js +25 -13
- package/lib/launcher/internal/runTtsc.js.map +1 -1
- package/lib/launcher/internal/runTtscserver.js +18 -3
- package/lib/launcher/internal/runTtscserver.js.map +1 -1
- package/lib/launcher/internal/runTtsx.js +2 -0
- package/lib/launcher/internal/runTtsx.js.map +1 -1
- package/lib/launcher/internal/runtimeHooks.d.ts +47 -3
- package/lib/launcher/internal/runtimeHooks.js +746 -64
- package/lib/launcher/internal/runtimeHooks.js.map +1 -1
- package/lib/plugin/internal/buildSourcePlugin.d.ts +62 -8
- package/lib/plugin/internal/buildSourcePlugin.js +754 -72
- package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
- package/lib/plugin/internal/capabilityResolutionCache.d.ts +76 -0
- package/lib/plugin/internal/capabilityResolutionCache.js +224 -0
- package/lib/plugin/internal/capabilityResolutionCache.js.map +1 -0
- package/lib/plugin/internal/loadProjectPlugins.d.ts +17 -5
- package/lib/plugin/internal/loadProjectPlugins.js +1339 -77
- package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
- package/lib/plugin/resolveCapabilityPlugins.d.ts +55 -0
- package/lib/plugin/resolveCapabilityPlugins.js +118 -0
- package/lib/plugin/resolveCapabilityPlugins.js.map +1 -0
- package/lib/register.d.ts +1 -0
- package/lib/register.js +46 -0
- package/lib/register.js.map +1 -0
- package/lib/structures/ITtscCompilerContext.d.ts +6 -3
- package/lib/structures/ITtscCompilerTransformation.d.ts +38 -0
- package/lib/structures/ITtscPlugin.d.ts +33 -0
- package/package.json +16 -9
- package/shim/ast/parent.go +16 -3
- package/shim/checker/enums_gen.go +11 -0
- package/shim/checker/go.mod +9 -0
- package/shim/checker/go.sum +22 -0
- package/shim/checker/shim.go +78 -0
- package/shim/compiler/emithook.go +10 -0
- package/shim/compiler/incremental.go +45 -8
- package/src/TtscCompiler.ts +55 -33
- package/src/compiler/internal/captureProcessOutput.ts +3 -2
- package/src/compiler/internal/compileProjectInMemory.ts +4 -3
- package/src/compiler/internal/project/createNativeProjectContextArgs.ts +19 -4
- package/src/compiler/internal/resolveEmittedJavaScript.ts +6 -0
- package/src/compiler/internal/runBuild.ts +102 -39
- package/src/compiler/internal/runSingleFileEmit.ts +2 -2
- package/src/compiler/internal/sharedHostHelpers.ts +44 -0
- package/src/compiler/internal/startResidentTransform.ts +12 -3
- package/src/compiler/internal/transformProjectInMemory.ts +410 -39
- package/src/flags/schema.ts +12 -5
- package/src/index.ts +1 -0
- package/src/internal/createCanonicalTempDirectory.ts +53 -0
- package/src/internal/projectInputPathIdentity.ts +17 -4
- package/src/internal/resolveNodeBinary.ts +173 -0
- package/src/internal/resolveSafeCacheCleanupTargets.ts +85 -0
- package/src/launcher/internal/prepareExecution.ts +50 -14
- package/src/launcher/internal/runTtsc.ts +24 -13
- package/src/launcher/internal/runTtscserver.ts +18 -3
- package/src/launcher/internal/runTtsx.ts +2 -0
- package/src/launcher/internal/runtimeHooks.ts +934 -68
- package/src/plugin/internal/buildSourcePlugin.ts +984 -79
- package/src/plugin/internal/capabilityResolutionCache.ts +305 -0
- package/src/plugin/internal/loadProjectPlugins.ts +1718 -104
- package/src/plugin/resolveCapabilityPlugins.ts +171 -0
- package/src/register.ts +47 -0
- package/src/structures/ITtscCompilerContext.ts +6 -3
- package/src/structures/ITtscCompilerTransformation.ts +46 -0
- package/src/structures/ITtscPlugin.ts +36 -0
- package/tools/shim_audit/baseline.json +0 -2
- package/utility/host.go +18 -3
- package/lib/internal/assertSafeExplicitCacheDirectory.d.ts +0 -5
- package/lib/internal/assertSafeExplicitCacheDirectory.js +0 -26
- package/lib/internal/assertSafeExplicitCacheDirectory.js.map +0 -1
- 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 :=
|
|
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
|
+
}
|