ttsc 0.33.0-next.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 (68) hide show
  1. package/driver/configloaderscratch/canonical_directory_other.go +34 -0
  2. package/driver/configloaderscratch/canonical_directory_windows.go +60 -0
  3. package/driver/configloaderscratch/identity_temp_base.go +81 -0
  4. package/driver/configloaderscratch/identity_temp_base_posix_test.go +124 -0
  5. package/driver/configloaderscratch/identity_temp_base_windows_test.go +156 -0
  6. package/driver/emit_provenance.go +7 -5
  7. package/driver/resolutioninputs/recorder.cjs +16 -4
  8. package/driver/rewrite.go +25 -16
  9. package/driver/write_file_if_changed.go +69 -0
  10. package/driver/write_file_if_changed_test.go +148 -0
  11. package/lib/compiler/internal/build/BuildExecution.d.ts +11 -7
  12. package/lib/compiler/internal/build/BuildExecution.js +22 -8
  13. package/lib/compiler/internal/build/BuildExecution.js.map +1 -1
  14. package/lib/compiler/internal/build/ResidentCheckWatchSession.d.ts +10 -10
  15. package/lib/compiler/internal/build/ResidentCheckWatchSession.js +19 -14
  16. package/lib/compiler/internal/build/ResidentCheckWatchSession.js.map +1 -1
  17. package/lib/compiler/internal/build/RunBuildOptions.d.ts +8 -1
  18. package/lib/compiler/internal/build/TsgoArguments.d.ts +16 -0
  19. package/lib/compiler/internal/build/TsgoArguments.js +19 -0
  20. package/lib/compiler/internal/build/TsgoArguments.js.map +1 -1
  21. package/lib/compiler/internal/captureProcessOutput.d.ts +16 -13
  22. package/lib/compiler/internal/captureProcessOutput.js +22 -14
  23. package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
  24. package/lib/compiler/internal/startResidentTransform.js +3 -1
  25. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  26. package/lib/compiler/internal/transformProjectInMemory.js +5 -1
  27. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  28. package/lib/launcher/internal/runtime/PluginDescriptorInputReport.d.ts +43 -0
  29. package/lib/launcher/internal/runtime/PluginDescriptorInputReport.js +106 -0
  30. package/lib/launcher/internal/runtime/PluginDescriptorInputReport.js.map +1 -0
  31. package/lib/launcher/internal/runtime/installRuntimeHooks.js +15 -29
  32. package/lib/launcher/internal/runtime/installRuntimeHooks.js.map +1 -1
  33. package/lib/launcher/internal/ttscserver/runTtscserver.js +1 -0
  34. package/lib/launcher/internal/ttscserver/runTtscserver.js.map +1 -1
  35. package/lib/plugin/internal/CapabilityResolutionFormat.js +4 -3
  36. package/lib/plugin/internal/CapabilityResolutionFormat.js.map +1 -1
  37. package/lib/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.js +6 -0
  38. package/lib/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.js.map +1 -1
  39. package/lib/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.js +6 -0
  40. package/lib/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.js.map +1 -1
  41. package/lib/plugin/internal/load/PluginDescriptorEvaluationCache.js +3 -3
  42. package/lib/plugin/internal/load/PluginDescriptorEvaluationCache.js.map +1 -1
  43. package/lib/plugin/internal/load/descriptorRecordEnvironment.d.ts +15 -0
  44. package/lib/plugin/internal/load/descriptorRecordEnvironment.js +24 -0
  45. package/lib/plugin/internal/load/descriptorRecordEnvironment.js.map +1 -0
  46. package/lib/plugin/internal/load/loadProjectPlugins.js +28 -26
  47. package/lib/plugin/internal/load/loadProjectPlugins.js.map +1 -1
  48. package/lib/plugin/internal/source/selectedRecordIdentityTempParent.d.ts +18 -0
  49. package/lib/plugin/internal/source/selectedRecordIdentityTempParent.js +46 -0
  50. package/lib/plugin/internal/source/selectedRecordIdentityTempParent.js.map +1 -0
  51. package/package.json +8 -8
  52. package/src/compiler/internal/build/BuildExecution.ts +30 -10
  53. package/src/compiler/internal/build/ResidentCheckWatchSession.ts +23 -22
  54. package/src/compiler/internal/build/RunBuildOptions.ts +9 -1
  55. package/src/compiler/internal/build/TsgoArguments.ts +22 -0
  56. package/src/compiler/internal/captureProcessOutput.ts +28 -14
  57. package/src/compiler/internal/startResidentTransform.ts +7 -1
  58. package/src/compiler/internal/transformProjectInMemory.ts +9 -1
  59. package/src/launcher/internal/runtime/PluginDescriptorInputReport.ts +100 -0
  60. package/src/launcher/internal/runtime/installRuntimeHooks.ts +57 -55
  61. package/src/launcher/internal/ttscserver/runTtscserver.ts +5 -0
  62. package/src/plugin/internal/CapabilityResolutionFormat.ts +4 -3
  63. package/src/plugin/internal/load/COMMONJS_PLUGIN_DESCRIPTOR_SHIM_SOURCE.ts +4 -0
  64. package/src/plugin/internal/load/PLUGIN_DESCRIPTOR_SHIM_SOURCE.ts +4 -0
  65. package/src/plugin/internal/load/PluginDescriptorEvaluationCache.ts +3 -3
  66. package/src/plugin/internal/load/descriptorRecordEnvironment.ts +24 -0
  67. package/src/plugin/internal/load/loadProjectPlugins.ts +45 -29
  68. package/src/plugin/internal/source/selectedRecordIdentityTempParent.ts +40 -0
@@ -0,0 +1,34 @@
1
+ //go:build !windows
2
+
3
+ package configloaderscratch
4
+
5
+ import (
6
+ "os"
7
+ "path/filepath"
8
+ )
9
+
10
+ // canonicalDirectory resolves the existing directory on non-Windows hosts.
11
+ func canonicalDirectory(path string) (string, error) {
12
+ return filepath.EvalSymlinks(path)
13
+ }
14
+
15
+ // containsDirectory compares opened filesystem identities so case-only aliases
16
+ // on a case-insensitive POSIX volume cannot hide an ancestor relationship.
17
+ func containsDirectory(base, child string) (bool, error) {
18
+ baseInfo, err := os.Stat(base)
19
+ if err != nil {
20
+ return false, err
21
+ }
22
+ for current := filepath.Clean(child); ; current = filepath.Dir(current) {
23
+ info, err := os.Stat(current)
24
+ if err != nil {
25
+ return false, err
26
+ }
27
+ if os.SameFile(baseInfo, info) {
28
+ return true, nil
29
+ }
30
+ if parent := filepath.Dir(current); parent == current {
31
+ return false, nil
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,60 @@
1
+ //go:build windows
2
+
3
+ package configloaderscratch
4
+
5
+ import (
6
+ "path/filepath"
7
+ "strings"
8
+
9
+ "golang.org/x/sys/windows"
10
+ )
11
+
12
+ // canonicalDirectory returns the opened directory's final filesystem path,
13
+ // including through NTFS junction ancestors that EvalSymlinks can leave intact.
14
+ func canonicalDirectory(path string) (string, error) {
15
+ pointer, err := windows.UTF16PtrFromString(path)
16
+ if err != nil {
17
+ return "", err
18
+ }
19
+ handle, err := windows.CreateFile(
20
+ pointer,
21
+ windows.FILE_READ_ATTRIBUTES,
22
+ windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
23
+ nil,
24
+ windows.OPEN_EXISTING,
25
+ windows.FILE_FLAG_BACKUP_SEMANTICS,
26
+ 0,
27
+ )
28
+ if err != nil {
29
+ return "", err
30
+ }
31
+ defer windows.CloseHandle(handle)
32
+
33
+ buffer := make([]uint16, windows.MAX_PATH)
34
+ for {
35
+ length, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), 0)
36
+ if err != nil {
37
+ return "", err
38
+ }
39
+ if length < uint32(len(buffer)) {
40
+ resolved := windows.UTF16ToString(buffer[:length])
41
+ if strings.HasPrefix(resolved, `\\?\UNC\`) {
42
+ resolved = `\\` + strings.TrimPrefix(resolved, `\\?\UNC\`)
43
+ } else {
44
+ resolved = strings.TrimPrefix(resolved, `\\?\`)
45
+ }
46
+ return filepath.Clean(resolved), nil
47
+ }
48
+ buffer = make([]uint16, length+1)
49
+ }
50
+ }
51
+
52
+ // containsDirectory compares Windows path spelling after final-path resolution.
53
+ func containsDirectory(base, child string) (bool, error) {
54
+ relative, err := filepath.Rel(base, child)
55
+ if err != nil {
56
+ return false, err
57
+ }
58
+ return relative == "." ||
59
+ (relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)), nil
60
+ }
@@ -0,0 +1,81 @@
1
+ package configloaderscratch
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ )
8
+
9
+ // IdentityTempBase selects the existing identities directory in the current
10
+ // invocation's record store when Windows TEMP is on another drive or TEMP
11
+ // contains the config directory. The directory must be an ordinary direct child
12
+ // on the config's lexical drive when one exists, and it cannot contain the config
13
+ // directory. Scratch changes only the existing identities directory's entries, even when that
14
+ // directory is below the config directory. An empty result preserves the
15
+ // loader's existing placement.
16
+ //
17
+ // @evidence contracts/common.md#principled-implementation The selected record store already owns its identities child before plugin evaluation; resolved directory identities, child metadata, Windows lexical drive comparison, and ancestry checks ensure identities is not the config directory or its ancestor. Scratch changes only identities entries, not the config directory's direct entries or metadata.
18
+ // @evidence contracts/common.md#clear-and-simple-design This selector only chooses an optional base; each loader retains its own historical TEMP fallback and creation/cleanup lifetime.
19
+ // @evidence contracts/common.md#prohibited-implementation-shortcuts The caller supplies its selected store root, and each path premise is checked on disk rather than inferred from a package or operating-system name.
20
+ // @evidence contracts/common.md#meaningful-documentation The comment states the store's ownership, required directory relationship, and empty-result meaning.
21
+ // @evidence contracts/portability.md#os-neutral-implementation IsAbs accepts POSIX absolute paths despite their empty VolumeName; EvalSymlinks and SameFile resolve POSIX links and case aliases. On Windows, VolumeName keeps TypeScript-Go drive spelling and opened directory handles resolve junctions. Unavailable directories fail closed; no URL form or process argument is used.
22
+ // @evidence contracts/performance.md#efficient-algorithms Windows uses at most four directory opens and constant path operations; POSIX compares directory identities along at most two bounded ancestor walks. Neither scans directory contents or source bytes.
23
+ // @evidenceExclude contracts/performance.md#reuse-equivalent-work The store and its links may change between config loads, so each call observes current metadata.
24
+ // @evidenceExclude contracts/performance.md#bound-retention-and-release-resources Metadata operations close within their calls; this selector retains no handle, directory, or task, while its caller owns temporary creation and removal.
25
+ func IdentityTempBase(location, systemTemp, recordRoot string) string {
26
+ if recordRoot == "" || !filepath.IsAbs(recordRoot) {
27
+ return ""
28
+ }
29
+ if !filepath.IsAbs(location) {
30
+ return ""
31
+ }
32
+ volume := filepath.VolumeName(location)
33
+ configDir := filepath.Dir(location)
34
+ physicalConfigDir, err := canonicalDirectory(configDir)
35
+ if err != nil {
36
+ return ""
37
+ }
38
+ if strings.EqualFold(filepath.VolumeName(systemTemp), volume) {
39
+ lexicalContains := false
40
+ if volume != "" {
41
+ lexicalContains, err = containsDirectory(systemTemp, configDir)
42
+ if err != nil {
43
+ return ""
44
+ }
45
+ }
46
+ if !lexicalContains {
47
+ physicalTemp, err := canonicalDirectory(systemTemp)
48
+ if err != nil {
49
+ return ""
50
+ }
51
+ physicalContains, err := containsDirectory(physicalTemp, physicalConfigDir)
52
+ if err != nil || !physicalContains {
53
+ return ""
54
+ }
55
+ }
56
+ }
57
+ physicalRoot, err := canonicalDirectory(recordRoot)
58
+ if err != nil {
59
+ return ""
60
+ }
61
+ identity := filepath.Join(recordRoot, "identities")
62
+ entry, err := os.Lstat(identity)
63
+ if err != nil || !entry.IsDir() || entry.Mode()&os.ModeSymlink != 0 {
64
+ return ""
65
+ }
66
+ if _, err := os.Readlink(identity); err == nil {
67
+ return ""
68
+ }
69
+ physicalIdentity, err := canonicalDirectory(identity)
70
+ if err != nil || filepath.Dir(physicalIdentity) != physicalRoot ||
71
+ !strings.EqualFold(filepath.VolumeName(physicalIdentity), volume) {
72
+ return ""
73
+ }
74
+ if strings.EqualFold(filepath.VolumeName(physicalIdentity), filepath.VolumeName(physicalConfigDir)) {
75
+ contained, err := containsDirectory(physicalIdentity, physicalConfigDir)
76
+ if err != nil || contained {
77
+ return ""
78
+ }
79
+ }
80
+ return physicalIdentity
81
+ }
@@ -0,0 +1,124 @@
1
+ //go:build !windows
2
+
3
+ package configloaderscratch
4
+
5
+ import (
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ // TestIdentityTempBaseKeepsPOSIXConfigRootStable verifies an existing record
13
+ // store receives loader scratch when TMPDIR contains the config directory.
14
+ //
15
+ // 1. Create a project and a separate record store with an ordinary identities child.
16
+ // 2. Select identities for ancestor TMPDIR, then create and remove scratch without changing the project directory.
17
+ // 3. Preserve independent TMPDIR and relative-config behavior, and reject linked or ancestor identities.
18
+ //
19
+ // @evidence contracts/testing.md#behavioral-verification The selector returns an existing identities child for ancestor TMPDIR, and actual MkdirTemp/RemoveAll there leaves the project timestamp and cache contents unchanged; unsupported layouts return no preference.
20
+ // @evidence contracts/testing.md#independent-expectations The authored sibling directories, before/after project stat, and empty identity listing independently establish placement and cleanup instead of deriving them from the selector.
21
+ // @evidence contracts/testing.md#distinguishing-cases Ancestor, symlinked ancestor, and available case-only ancestor TMPDIR select identities; independent TMPDIR, relative config, missing or linked identity, and an identity ancestor of the config keep the existing fallback.
22
+ // @evidence contracts/testing.md#execution-ownership This named Go unit exercises the portable selector and filesystem directly without launching a compiler, config evaluator, or native plugin.
23
+ func TestIdentityTempBaseKeepsPOSIXConfigRootStable(t *testing.T) {
24
+ root := t.TempDir()
25
+ project := filepath.Join(root, "project")
26
+ recordRoot := filepath.Join(root, "cache")
27
+ identity := filepath.Join(recordRoot, "identities")
28
+ for _, dir := range []string{project, identity} {
29
+ if err := os.MkdirAll(dir, 0o755); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ }
33
+ config := filepath.Join(project, "banner.config.ts")
34
+ want, err := filepath.EvalSymlinks(identity)
35
+ if err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ if got := IdentityTempBase(config, project, recordRoot); got != want {
39
+ t.Fatalf("ancestor TMPDIR selected %q, want %q", got, want)
40
+ }
41
+ before, err := os.Stat(project)
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ scratch, err := os.MkdirTemp(want, "ttsc-banner-config-")
46
+ if err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ t.Cleanup(func() {
50
+ if err := os.RemoveAll(scratch); err != nil {
51
+ t.Errorf("remove identity scratch: %v", err)
52
+ }
53
+ })
54
+ if err := os.RemoveAll(scratch); err != nil {
55
+ t.Fatal(err)
56
+ }
57
+ after, err := os.Stat(project)
58
+ if err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ if !before.ModTime().Equal(after.ModTime()) {
62
+ t.Fatalf("project directory timestamp changed: %v -> %v", before.ModTime(), after.ModTime())
63
+ }
64
+ entries, err := os.ReadDir(identity)
65
+ if err != nil || len(entries) != 0 {
66
+ t.Fatalf("identity scratch remains: %v, %v", entries, err)
67
+ }
68
+ separateTemp := filepath.Join(root, "separate-temp")
69
+ if err := os.Mkdir(separateTemp, 0o755); err != nil {
70
+ t.Fatal(err)
71
+ }
72
+ if got := IdentityTempBase(config, separateTemp, recordRoot); got != "" {
73
+ t.Fatalf("independent TMPDIR selected identities: %q", got)
74
+ }
75
+ if got := IdentityTempBase("banner.config.ts", project, recordRoot); got != "" {
76
+ t.Fatalf("relative config selected identities: %q", got)
77
+ }
78
+ linkedTemp := filepath.Join(root, "linked-temp")
79
+ if err := os.Symlink(project, linkedTemp); err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ if got := IdentityTempBase(config, linkedTemp, recordRoot); got != want {
83
+ t.Fatalf("linked ancestor TMPDIR selected %q, want %q", got, want)
84
+ }
85
+ linkedRoot := filepath.Join(root, "linked-cache")
86
+ if err := os.Symlink(recordRoot, linkedRoot); err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ if got := IdentityTempBase(config, project, linkedRoot); got != want {
90
+ t.Fatalf("linked store root selected %q, want %q", got, want)
91
+ }
92
+ missingRoot := filepath.Join(root, "missing-cache")
93
+ if got := IdentityTempBase(config, project, missingRoot); got != "" {
94
+ t.Fatalf("missing store selected identities: %q", got)
95
+ }
96
+ linkedIdentityRoot := filepath.Join(root, "linked-identity-cache")
97
+ if err := os.Mkdir(linkedIdentityRoot, 0o755); err != nil {
98
+ t.Fatal(err)
99
+ }
100
+ if err := os.Symlink(identity, filepath.Join(linkedIdentityRoot, "identities")); err != nil {
101
+ t.Fatal(err)
102
+ }
103
+ if got := IdentityTempBase(config, project, linkedIdentityRoot); got != "" {
104
+ t.Fatalf("linked identity child selected: %q", got)
105
+ }
106
+ nested := filepath.Join(identity, "nested")
107
+ if err := os.Mkdir(nested, 0o755); err != nil {
108
+ t.Fatal(err)
109
+ }
110
+ if got := IdentityTempBase(filepath.Join(nested, "banner.config.ts"), project, recordRoot); got != "" {
111
+ t.Fatalf("identity ancestor of config selected: %q", got)
112
+ }
113
+ alternateCase := strings.ToUpper(project)
114
+ projectInfo, projectErr := os.Stat(project)
115
+ aliasInfo, aliasErr := os.Stat(alternateCase)
116
+ if projectErr == nil && aliasErr == nil && os.SameFile(projectInfo, aliasInfo) {
117
+ if got := IdentityTempBase(config, alternateCase, recordRoot); got != want {
118
+ t.Fatalf("case-only ancestor TMPDIR selected %q, want %q", got, want)
119
+ }
120
+ if got := IdentityTempBase(filepath.Join(strings.ToUpper(identity), "nested", "banner.config.ts"), project, recordRoot); got != "" {
121
+ t.Fatalf("case-only identity ancestor selected: %q", got)
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,156 @@
1
+ package configloaderscratch
2
+
3
+ import (
4
+ "os"
5
+ "os/exec"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+ )
10
+
11
+ // TestIdentityTempBaseUsesOnlyAnExistingOrdinaryChild verifies the shared
12
+ // loader selector accepts an owned sibling directory and same-drive junction
13
+ // ancestors while rejecting linked children, missing stores, and ancestors.
14
+ //
15
+ // 1. Create an ordinary project and record-store sibling, then select its preexisting identities child.
16
+ // 2. Accept same-drive cache/config junctions and ancestor TEMP, but preserve independent same-drive TEMP.
17
+ // 3. Accept lexical-volume identities for a cross-drive config junction and reject a physical-volume store.
18
+ // 4. Create and remove a scratch child under the selected base and check the project directory stays fixed.
19
+ //
20
+ // @evidence contracts/testing.md#behavioral-verification Calls the shared selector on real Windows directories and junctions, then performs temporary creation and removal at its selected base; unsupported layouts return no preferred base and the project directory's timestamp stays unchanged.
21
+ // @evidence contracts/testing.md#independent-expectations The authored sibling, missing child, linked child, same-drive junction and ancestor layouts define valid and invalid paths before the selector runs; the project stat and empty identities listing independently detect root churn and scratch residue.
22
+ // @evidence contracts/testing.md#distinguishing-cases Same-drive cache/config junctions and ancestor TEMP contrast with a cross-drive config junction, independent same-volume TEMP, missing and linked identities, and a config inside identities; callers separately test legacy fallback creation.
23
+ // @evidence contracts/testing.md#execution-ownership This named Go unit calls the shared filesystem selector directly; cmd only creates junction fixtures, and no compiler, config evaluator, or native plugin runs.
24
+ func TestIdentityTempBaseUsesOnlyAnExistingOrdinaryChild(t *testing.T) {
25
+ root := t.TempDir()
26
+ project := filepath.Join(root, "project")
27
+ recordRoot := filepath.Join(root, "cache")
28
+ identity := filepath.Join(recordRoot, "identities")
29
+ for _, dir := range []string{project, identity} {
30
+ if err := os.MkdirAll(dir, 0o755); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ }
34
+ config := filepath.Join(project, "banner.config.ts")
35
+ otherTemp := `Z:\ttsc-test-temp`
36
+ if strings.EqualFold(filepath.VolumeName(root), "Z:") {
37
+ otherTemp = `Y:\ttsc-test-temp`
38
+ }
39
+ physicalIdentity, err := filepath.EvalSymlinks(identity)
40
+ if err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ if got := IdentityTempBase(config, otherTemp, recordRoot); got != physicalIdentity {
44
+ t.Fatalf("identity base mismatch: %q != %q", got, physicalIdentity)
45
+ }
46
+ separateTemp := filepath.Join(root, "separate-temp")
47
+ if err := os.Mkdir(separateTemp, 0o755); err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ if got := IdentityTempBase(config, separateTemp, recordRoot); got != "" {
51
+ t.Fatalf("independent same-volume TEMP selected identities: %q", got)
52
+ }
53
+ for _, ancestorTemp := range []string{project, root} {
54
+ if got := IdentityTempBase(config, ancestorTemp, recordRoot); got != physicalIdentity {
55
+ t.Fatalf("ancestor TEMP did not select identities: %q", got)
56
+ }
57
+ }
58
+ if got := IdentityTempBase(config, otherTemp, filepath.Join(root, "missing")); got != "" {
59
+ t.Fatalf("missing store selected identities: %q", got)
60
+ }
61
+ before, err := os.Stat(project)
62
+ if err != nil {
63
+ t.Fatal(err)
64
+ }
65
+ scratch, err := os.MkdirTemp(physicalIdentity, "ttsc-config-loader-")
66
+ if err != nil {
67
+ t.Fatal(err)
68
+ }
69
+ t.Cleanup(func() {
70
+ if err := os.RemoveAll(scratch); err != nil {
71
+ t.Errorf("remove identity scratch: %v", err)
72
+ }
73
+ })
74
+ if err := os.RemoveAll(scratch); err != nil {
75
+ t.Fatal(err)
76
+ }
77
+ after, err := os.Stat(project)
78
+ if err != nil {
79
+ t.Fatal(err)
80
+ }
81
+ if !before.ModTime().Equal(after.ModTime()) {
82
+ t.Fatalf("project directory timestamp changed: %v -> %v", before.ModTime(), after.ModTime())
83
+ }
84
+ entries, err := os.ReadDir(identity)
85
+ if err != nil || len(entries) != 0 {
86
+ t.Fatalf("identity scratch remains: %v, %v", entries, err)
87
+ }
88
+ linkedRoot := filepath.Join(root, "linked-cache")
89
+ if out, err := exec.Command("cmd", "/c", "mklink", "/J", linkedRoot, recordRoot).CombinedOutput(); err != nil {
90
+ t.Fatalf("cache-root junction failed: %v: %s", err, out)
91
+ }
92
+ if got := IdentityTempBase(config, otherTemp, linkedRoot); got != physicalIdentity {
93
+ t.Fatalf("same-drive linked root did not select canonical identities: %q", got)
94
+ }
95
+ linkedProject := filepath.Join(root, "linked-project")
96
+ if out, err := exec.Command("cmd", "/c", "mklink", "/J", linkedProject, project).CombinedOutput(); err != nil {
97
+ t.Fatalf("project junction failed: %v: %s", err, out)
98
+ }
99
+ if got := IdentityTempBase(filepath.Join(linkedProject, "banner.config.ts"), otherTemp, recordRoot); got != physicalIdentity {
100
+ t.Fatalf("same-drive linked config did not select canonical identities: %q", got)
101
+ }
102
+ linkedTemp := filepath.Join(root, "linked-temp")
103
+ if out, err := exec.Command("cmd", "/c", "mklink", "/J", linkedTemp, project).CombinedOutput(); err != nil {
104
+ t.Fatalf("TEMP junction failed: %v: %s", err, out)
105
+ }
106
+ if got := IdentityTempBase(config, linkedTemp, recordRoot); got != physicalIdentity {
107
+ t.Fatalf("physical ancestor TEMP did not select identities: %q", got)
108
+ }
109
+ linkedIdentityRoot := filepath.Join(root, "linked-identity-cache")
110
+ if err := os.Mkdir(linkedIdentityRoot, 0o755); err != nil {
111
+ t.Fatal(err)
112
+ }
113
+ if out, err := exec.Command("cmd", "/c", "mklink", "/J", filepath.Join(linkedIdentityRoot, "identities"), identity).CombinedOutput(); err != nil {
114
+ t.Fatalf("identity junction failed: %v: %s", err, out)
115
+ }
116
+ if got := IdentityTempBase(config, otherTemp, linkedIdentityRoot); got != "" {
117
+ t.Fatalf("linked identities selected: %q", got)
118
+ }
119
+ nested := filepath.Join(identity, "nested")
120
+ if err := os.Mkdir(nested, 0o755); err != nil {
121
+ t.Fatal(err)
122
+ }
123
+ if got := IdentityTempBase(filepath.Join(nested, "banner.config.ts"), otherTemp, recordRoot); got != "" {
124
+ t.Fatalf("config inside identities selected ancestor: %q", got)
125
+ }
126
+ checkout, err := os.Getwd()
127
+ if err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ if !strings.EqualFold(filepath.VolumeName(checkout), filepath.VolumeName(root)) {
131
+ packageRoot := filepath.Clean(filepath.Join(checkout, "..", ".."))
132
+ otherPhysical, err := os.MkdirTemp(packageRoot, "ttsc-banner-junction-test-")
133
+ if err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ t.Cleanup(func() {
137
+ if err := os.RemoveAll(otherPhysical); err != nil {
138
+ t.Errorf("remove cross-volume fixture: %v", err)
139
+ }
140
+ })
141
+ if err := os.Mkdir(filepath.Join(otherPhysical, "identities"), 0o755); err != nil {
142
+ t.Fatal(err)
143
+ }
144
+ configAlias := filepath.Join(root, "linked-other-volume-config")
145
+ if out, err := exec.Command("cmd", "/c", "mklink", "/J", configAlias, otherPhysical).CombinedOutput(); err != nil {
146
+ t.Fatalf("cross-volume config junction failed: %v: %s", err, out)
147
+ }
148
+ linkedConfig := filepath.Join(configAlias, "banner.config.ts")
149
+ if got := IdentityTempBase(linkedConfig, otherTemp, recordRoot); got != physicalIdentity {
150
+ t.Fatalf("lexical-volume identities not selected for linked config: %q", got)
151
+ }
152
+ if got := IdentityTempBase(linkedConfig, otherTemp, otherPhysical); got != "" {
153
+ t.Fatalf("physical-volume identities selected for linked config: %q", got)
154
+ }
155
+ }
156
+ }
@@ -14,15 +14,17 @@ import (
14
14
  )
15
15
 
16
16
  // NewEmitProvenanceRecorder wraps a writer and returns a build-owned snapshot
17
- // function for successfully written JavaScript outputs. The compiler's actual
17
+ // function for successfully materialized JavaScript outputs. The compiler's actual
18
18
  // eligible source list and output resolver determine candidates; neither sibling
19
19
  // filenames nor source maps establish ownership.
20
20
  //
21
21
  // Snapshot keys are absolute native output paths. Values are sorted, distinct
22
22
  // physical source paths recorded by this compiler generation's input observer.
23
- // An empty value means an output was written but its eligible owners or some
23
+ // An empty value means an output was emitted but its eligible owners or some
24
24
  // owner's physical proof were unavailable or inconsistent. Multiple values
25
- // preserve ambiguity. An empty object means no JavaScript write succeeded.
25
+ // preserve ambiguity. An empty object means no JavaScript output succeeded.
26
+ // A writer may leave an existing byte-identical output in place and return
27
+ // success; that output remains part of this generation's emitted result.
26
28
  //
27
29
  // Call the snapshot after emission finishes. It remains attached to its captured
28
30
  // generation's ledger and never resolves an alias against post-build disk state.
@@ -32,12 +34,12 @@ import (
32
34
  // the supplied writer must not reenter either returned closure. Writer errors
33
35
  // prevent recording that callback even if the writer already had partial effects.
34
36
  //
35
- // @evidence contracts/common.md#principled-implementation Native eligible sources and resolved script destinations intersect only successful unskipped writes; all candidate generation-time physical proofs are required, with unknown and multiple owners represented explicitly.
37
+ // @evidence contracts/common.md#principled-implementation Native eligible sources and resolved script destinations intersect only successful unskipped emit callbacks, including byte-identical existing outputs; all candidate generation-time physical proofs are required, with unknown and multiple owners represented explicitly.
36
38
  // @evidence contracts/common.md#clear-and-simple-design One compiler-owned candidate index, one serialized writer and one snapshot separate eligibility, successful publication and physical provenance without a same-stem fallback.
37
39
  // @evidence contracts/common.md#prohibited-implementation-shortcuts The recorder delegates supported emit-path helpers and actual writers; later realpath, fixture filenames or extension precedence cannot fabricate a unique source owner.
38
40
  // @evidence contracts/common.md#meaningful-documentation Native paragraphs explain snapshot timing, native keys, physical proof, ambiguity, unknown values and the non-atomic ledger limitation following the documentation skill.
39
41
  // @evidence contracts/portability.md#os-neutral-implementation Compiler path normalization and its coordinate case policy index candidate collisions; recorded native physical paths prove source identity separately from that policy, preserving lexical output names without an OS-derived identity guess.
40
- // @evidence contracts/performance.md#efficient-algorithms Latched linked hooks and native eligibility/output-path helpers precede the source index. Candidate/path normalization and text keys contribute work; each callback delegates arbitrary writer work, then records a successful script row. Snapshot visits each written row's candidates, compares captured proofs, deduplicates physical paths and sorts each owner list with path-text comparison costs; shared candidate lists can be revisited across written spellings.
42
+ // @evidence contracts/performance.md#efficient-algorithms Latched linked hooks and native eligibility/output-path helpers precede the source index. Candidate/path normalization and text keys contribute work; each callback delegates arbitrary writer work, then records a successful script row even when the writer reuses matching bytes. Snapshot visits each row's candidates, compares captured proofs, deduplicates physical paths and sorts each owner list with path-text comparison costs; shared candidate lists can be revisited across output spellings.
41
43
  // @evidence contracts/performance.md#reuse-equivalent-work One generation's native eligibility and output paths serve every writer callback; the snapshot reuses captured input observations without re-reading source bytes or recomputing an independent compiler Program.
42
44
  // @evidence contracts/performance.md#bound-retention-and-release-resources Returned closures retain the captured input ledger and uncapped candidate/write path lists until their owner releases them. Snapshots allocate independent maps/slices; delegated writer resource release belongs to that writer. Shared locking serializes writer and snapshot with no deadline or reentrant acquisition; no independent descriptor or lease is acquired here.
43
45
  func (p *Program) NewEmitProvenanceRecorder(writeFile shimcompiler.WriteFile) (shimcompiler.WriteFile, func() map[string][]string, error) {
@@ -775,10 +775,10 @@ function visitImportTargetCandidates(specifier, parent, extensions, visit) {
775
775
  * the input set without a hash, even when resolution observation is
776
776
  * complete.
777
777
  *
778
- * @evidence contracts/common.md#principled-implementation Before/after metadata plus repeated content and physical observations withdraw contradictory per-input proofs; the separate complete flag reports known observation capability gaps.
778
+ * @evidence contracts/common.md#principled-implementation Before/after metadata plus repeated content and physical observations withdraw contradictory per-input proofs; a selected file must have positive content and physical proof, while the separate complete flag reports known observation capability gaps.
779
779
  * @evidence contracts/common.md#clear-and-simple-design One evaluation owns its input ledger, resolution tokens and final envelope; callers explicitly invalidate capability rather than manipulating proof maps.
780
780
  * @evidence contracts/common.md#prohibited-implementation-shortcuts Unknown observations retain missing proofs and incomplete capability remains false; no expected digest or resolver monkeypatch makes reuse appear valid.
781
- * @evidence contracts/common.md#meaningful-documentation JSDoc describes hook ownership, entry recording, final reread and capability versus stability; returned operations have native descriptions and tags are separated.
781
+ * @evidence contracts/common.md#meaningful-documentation JSDoc describes hook ownership, entry recording, selected-file proof, final reread and capability versus stability; returned operations have native descriptions and tags are separated.
782
782
  * @evidence contracts/portability.md#os-neutral-implementation Native stat/realpath and URL conversion represent paths and identities; symlink ancestors are observed without equating an OS name to filesystem case policy.
783
783
  * @evidence contracts/performance.md#efficient-algorithms Sets/maps index paths, but candidate expansion and repeated pre/post/final observations still pay native stat/realpath/ancestor-link work and full file hashing; finish visits its captured input list and sorts output paths, with uncapped byte, path and manifest-expansion costs.
784
784
  * @evidence contracts/performance.md#reuse-equivalent-work Distinct candidates share the evaluation ledger and expanded bases; the first witness stays authoritative and contradictory later reads revoke its proof instead of refreshing it.
@@ -931,8 +931,8 @@ function createResolutionInputRecorder(options) {
931
931
  },
932
932
  /**
933
933
  * Settle a resolution: commit the fingerprints of the candidates it could
934
- * have read, record them again as they are now, and record the module it
935
- * selected.
934
+ * have read, record them again as they are now, and require positive proof
935
+ * for the file selected by a successful resolution.
936
936
  *
937
937
  * @param {{
938
938
  * importPending: object[];
@@ -1008,6 +1008,18 @@ function createResolutionInputRecorder(options) {
1008
1008
  recordedBases,
1009
1009
  );
1010
1010
  recordFile(resolved);
1011
+ const selected = asFile(resolved);
1012
+ if (
1013
+ selected !== undefined &&
1014
+ (typeof hashes.get(selected) !== "string" ||
1015
+ typeof realpaths.get(selected) !== "string")
1016
+ ) {
1017
+ inputs.add(selected);
1018
+ hashes.delete(selected);
1019
+ realpaths.delete(selected);
1020
+ signatures.delete(selected);
1021
+ unstable.add(selected);
1022
+ }
1011
1023
  },
1012
1024
  recordFile,
1013
1025
 
package/driver/rewrite.go CHANGED
@@ -157,10 +157,13 @@ func (p *Program) EmitAll(rs *RewriteSet, writeFile shimcompiler.WriteFile) (*sh
157
157
  // is the standard place to carry per-file cursors or an output map), so ttsc
158
158
  // owns the serialization rather than pushing goroutine-safety onto every
159
159
  // plugin author. See the emit-concurrency contract in the plugin docs.
160
+ // A watch-owned writer may leave an already identical file in place; that
161
+ // successful output remains in EmittedFiles. Rejected outDir paths are marked
162
+ // skipped and do not appear there.
160
163
  //
161
164
  // @evidence contracts/common.md#principled-implementation Native emission honors linked program hooks, compiler incremental-output policy, and output containment while omitting only ttsc text rewrites.
162
165
  // @evidence contracts/common.md#clear-and-simple-design One serialized writer callback wraps the compiler emit owner and shared diagnostic conversion.
163
- // @evidence contracts/common.md#prohibited-implementation-shortcuts Compiler emission is not patched or replaced with expected-output text; skipped writes are marked for accurate emitted-file reporting.
166
+ // @evidence contracts/common.md#prohibited-implementation-shortcuts Compiler emission is not patched or replaced with expected-output text; rejected outDir writes are marked skipped, while identical existing watch outputs remain successful emitted files.
164
167
  // @evidence contracts/common.md#meaningful-documentation Native prose distinguishes raw output and guaranteed callback serialization following the documentation skill.
165
168
  // @evidence contracts/portability.md#os-neutral-implementation Native containment resolves compiler paths with its case policy, and DefaultWriteFile uses native filesystem APIs.
166
169
  // @evidence contracts/performance.md#efficient-algorithms Native generation processes the current program and emitted text; serialized callbacks add lexical path-containment and default filesystem or arbitrary caller-writer costs. Result diagnostics require conversion. Native threading policy remains selected by the loaded program, with no measured claim that callback serialization is inexpensive.
@@ -185,8 +188,8 @@ func (p *Program) EmitAllRaw(writeFile shimcompiler.WriteFile) (*shimcompiler.Em
185
188
  wfMu.Lock()
186
189
  defer wfMu.Unlock()
187
190
  if p.outputEscapesOutDir(fileName) {
188
- // Marking the write skipped keeps the file out of EmitResult.EmittedFiles,
189
- // so callers reporting emitted counts don't include phantom outputs.
191
+ // Marking a rejected path skipped keeps it out of EmittedFiles. An
192
+ // existing identical output is still an emitted result and stays listed.
190
193
  if data != nil {
191
194
  data.SkippedDtsWrite = true
192
195
  }
@@ -243,8 +246,8 @@ func (p *Program) emit(rs *RewriteSet, target *ast.SourceFile, writeFile shimcom
243
246
  wfMu.Lock()
244
247
  defer wfMu.Unlock()
245
248
  if p.outputEscapesOutDir(fileName) {
246
- // See EmitAllRaw: mark the write skipped so EmitResult.EmittedFiles
247
- // reflects only files actually written.
249
+ // See EmitAllRaw: rejected paths are absent from EmittedFiles, while an
250
+ // existing identical watch output remains a successful result.
248
251
  if data != nil {
249
252
  data.SkippedDtsWrite = true
250
253
  }
@@ -316,19 +319,25 @@ func (p *Program) emitsBuildInfo() bool {
316
319
  }
317
320
 
318
321
  // DefaultWriteFile is the default disk writer used when EmitAll's caller does not
319
- // supply a custom WriteFile callback. Existing files are truncated; a failed
320
- // write can leave partial output. Requested creation modes are subject to the
321
- // native platform and umask, and do not replace existing file permissions.
322
+ // supply a custom WriteFile callback. Existing files are normally truncated; a
323
+ // watch child setting TTSC_WATCH_WRITE_IF_CHANGED=1 preserves byte-identical
324
+ // regular output files instead. A failed write can leave partial output.
325
+ // Requested creation modes are subject to the native platform and umask, and
326
+ // do not replace existing file permissions.
322
327
  //
323
- // @evidence contracts/common.md#principled-implementation Parent directories are created before the output is written, and native errors remain visible to emit callers.
324
- // @evidence contracts/common.md#clear-and-simple-design One directory-creation step precedes one file write.
325
- // @evidence contracts/common.md#prohibited-implementation-shortcuts The writer uses actual requested filenames without suppressing filesystem failures or relying on shell commands.
326
- // @evidence contracts/common.md#meaningful-documentation Native prose identifies the default callback role following the documentation skill.
327
- // @evidence contracts/portability.md#os-neutral-implementation filepath.Dir, os.MkdirAll, and os.WriteFile supply OS-neutral native operations without separator or command assumptions.
328
- // @evidence contracts/performance.md#efficient-algorithms Native parent creation can walk missing ancestors and query existing entries; path length/depth and output bytes drive delegated work. The text-to-byte conversion and file write process the supplied output without a directory enumeration or intermediate output collection.
329
- // @evidenceExclude contracts/performance.md#reuse-equivalent-work Each emit write is requested independently; this writer owns no reuse coordinator.
330
- // @evidence contracts/performance.md#bound-retention-and-release-resources os.WriteFile opens one output handle and attempts its closure after the write, including write failure; open failure acquires no handle. The adapter retains no handle or historical output buffer after returning, but created directories and partial or completed files remain on disk without rollback.
328
+ // @evidence contracts/common.md#principled-implementation The watch-only opt-in compares an existing writable regular output before deciding whether native writing is needed; all other writes use the original directory and file operations.
329
+ // @evidence contracts/common.md#clear-and-simple-design One opt-in decision selects the unchanged-output check or the original writer.
330
+ // @evidence contracts/common.md#prohibited-implementation-shortcuts Native file identity and bytes decide whether to skip; unknown state falls back to the original writer and its errors.
331
+ // @evidence contracts/common.md#meaningful-documentation Native prose states normal truncation, watch opt-in, partial failure and permission behavior.
332
+ // @evidence contracts/portability.md#os-neutral-implementation filepath.Dir, os.MkdirAll and os.WriteFile preserve native path and permission behavior; the optional check uses native file identity rather than path spelling.
333
+ // @evidence contracts/performance.md#efficient-algorithms Normal writes retain one directory creation and file write. Watch writes add metadata and streamed comparison of up to the existing output size, then write only when content differs or safe comparison fails; no prior-output cache is retained.
334
+ // @evidenceExclude contracts/performance.md#reuse-equivalent-work Each emit write is requested independently; this writer owns no cross-emit reuse coordinator.
335
+ // @evidence contracts/performance.md#bound-retention-and-release-resources The optional comparison closes its one read/write handle before return or fallback; os.WriteFile owns and closes the later write handle. No output buffer or handle survives this call, while created directories and partial or completed files remain on disk.
331
336
  func DefaultWriteFile(fileName, text string) error {
337
+ return writeFileWithWatchMode(fileName, text, os.Getenv("TTSC_WATCH_WRITE_IF_CHANGED") == "1")
338
+ }
339
+
340
+ func defaultWriteFile(fileName, text string) error {
332
341
  if dir := filepath.Dir(fileName); dir != "" {
333
342
  if err := os.MkdirAll(dir, 0o755); err != nil {
334
343
  return err