@ttsc/strip 0.26.1 → 0.27.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/driver/config.go +865 -44
- package/driver/strip.go +1 -1
- package/package.json +1 -1
- package/src/index.cjs +85 -0
- package/src/index.d.ts +7 -0
package/driver/config.go
CHANGED
|
@@ -3,6 +3,7 @@ package strip
|
|
|
3
3
|
import (
|
|
4
4
|
"bytes"
|
|
5
5
|
"context"
|
|
6
|
+
"crypto/sha256"
|
|
6
7
|
"encoding/json"
|
|
7
8
|
"fmt"
|
|
8
9
|
"os"
|
|
@@ -41,6 +42,14 @@ var allowedTsconfigKeys = map[string]struct{}{
|
|
|
41
42
|
// configuration from either an explicit configFile or an auto-discovered
|
|
42
43
|
// strip.config.* file. Returns the raw config map ready for parseStrip.
|
|
43
44
|
func loadStripConfigMap(pluginConfig map[string]any, cwd, tsconfigPath string) (map[string]any, error) {
|
|
45
|
+
return loadStripConfigMapWithReporter(pluginConfig, cwd, tsconfigPath, nil)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func loadStripConfigMapWithReporter(pluginConfig map[string]any, cwd, tsconfigPath string, reporter func(string)) (map[string]any, error) {
|
|
49
|
+
return loadStripConfigMapWithReporters(pluginConfig, cwd, tsconfigPath, reporter, nil, nil)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func loadStripConfigMapWithReporters(pluginConfig map[string]any, cwd, tsconfigPath string, reporter func(string), hashReporter, realpathReporter func(string, *string)) (map[string]any, error) {
|
|
44
53
|
// Reject any key that @ttsc/strip does not recognise. This surfaces
|
|
45
54
|
// stale inline keys (calls, statements) with a clear error so users
|
|
46
55
|
// migrate to a config file instead of silently using defaults.
|
|
@@ -55,6 +64,10 @@ func loadStripConfigMap(pluginConfig map[string]any, cwd, tsconfigPath string) (
|
|
|
55
64
|
}
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
// The discovery base directory doubles as the resolution root the config
|
|
68
|
+
// loader anchors its toolchain lookup on; see stripConfigToolAnchors.
|
|
69
|
+
resolutionRoot := stripDiscoveryBaseDir(cwd, tsconfigPath)
|
|
70
|
+
|
|
58
71
|
// Resolve the config file: explicit configFile wins over discovery.
|
|
59
72
|
configFilePath := ""
|
|
60
73
|
if rawCF, ok := pluginConfig["configFile"]; ok {
|
|
@@ -77,11 +90,12 @@ func loadStripConfigMap(pluginConfig map[string]any, cwd, tsconfigPath string) (
|
|
|
77
90
|
return map[string]any{}, nil
|
|
78
91
|
}
|
|
79
92
|
|
|
80
|
-
|
|
93
|
+
loaded, err := loadStripConfigFileWithInputs(configFilePath, resolutionRoot)
|
|
81
94
|
if err != nil {
|
|
82
95
|
return nil, err
|
|
83
96
|
}
|
|
84
|
-
|
|
97
|
+
reportStripConfigInputs(loaded.inputs, loaded.hashes, loaded.realpaths, reporter, hashReporter, realpathReporter)
|
|
98
|
+
cfg, ok := loaded.value.(map[string]any)
|
|
85
99
|
if !ok {
|
|
86
100
|
return nil, fmt.Errorf("@ttsc/strip: config file %s must export an object", configFilePath)
|
|
87
101
|
}
|
|
@@ -147,17 +161,123 @@ func resolveStripConfigFilePath(configPath, cwd, tsconfigPath string) string {
|
|
|
147
161
|
// loadStripConfigFile loads and deserializes a strip config file at location.
|
|
148
162
|
// The format is determined by extension: .json is parsed natively; .js/.cjs/.mjs
|
|
149
163
|
// run through a Node subprocess; .ts/.cts/.mts run through ttsx.
|
|
150
|
-
|
|
164
|
+
//
|
|
165
|
+
// resolutionRoot is the project directory the TypeScript branch anchors its
|
|
166
|
+
// toolchain resolution on when the config file's own ancestry answers nothing;
|
|
167
|
+
// see stripConfigToolAnchors. The JSON and JS branches spawn no ttsx and
|
|
168
|
+
// ignore it.
|
|
169
|
+
func loadStripConfigFile(location, resolutionRoot string) (any, error) {
|
|
170
|
+
loaded, err := loadStripConfigFileWithInputs(location, resolutionRoot)
|
|
171
|
+
return loaded.value, err
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
type stripLoadedConfig struct {
|
|
175
|
+
hashes map[string]*string
|
|
176
|
+
inputs []string
|
|
177
|
+
realpaths map[string]*string
|
|
178
|
+
value any
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func loadStripConfigFileWithInputs(location, resolutionRoot string) (stripLoadedConfig, error) {
|
|
151
182
|
ext := strings.ToLower(filepath.Ext(location))
|
|
152
183
|
switch ext {
|
|
153
184
|
case ".json":
|
|
154
|
-
|
|
185
|
+
body, err := os.ReadFile(location)
|
|
186
|
+
if err != nil {
|
|
187
|
+
return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: read config file %s: %w", location, err)
|
|
188
|
+
}
|
|
189
|
+
value, err := parseStripJSONConfigFile(location, body)
|
|
190
|
+
digest := fmt.Sprintf("%x", sha256.Sum256(body))
|
|
191
|
+
return stripLoadedConfig{hashes: map[string]*string{location: &digest}, inputs: []string{location}, realpaths: map[string]*string{location: stripPhysicalHostInput(location)}, value: value}, err
|
|
155
192
|
case ".js", ".cjs", ".mjs":
|
|
156
|
-
return
|
|
193
|
+
return loadStripScriptConfigFileWithInputs(location)
|
|
157
194
|
case ".ts", ".cts", ".mts":
|
|
158
|
-
return
|
|
195
|
+
return loadStripTypeScriptConfigFileWithInputs(location, resolutionRoot)
|
|
159
196
|
default:
|
|
160
|
-
return
|
|
197
|
+
return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: unsupported config file extension %q for %s", ext, location)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func reportStripConfigInputs(inputs []string, hashes, realpaths map[string]*string, reporter func(string), hashReporter, realpathReporter func(string, *string)) {
|
|
202
|
+
if reporter == nil && hashReporter == nil && realpathReporter == nil {
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
for _, input := range inputs {
|
|
206
|
+
if reporter != nil {
|
|
207
|
+
reporter(input)
|
|
208
|
+
}
|
|
209
|
+
if hashReporter != nil {
|
|
210
|
+
if hash, ok := hashes[input]; ok {
|
|
211
|
+
hashReporter(input, hash)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if realpathReporter != nil {
|
|
215
|
+
if realpath, ok := realpaths[input]; ok {
|
|
216
|
+
realpathReporter(input, realpath)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func stripPhysicalHostInput(file string) *string {
|
|
223
|
+
resolved, err := filepath.Abs(file)
|
|
224
|
+
if err != nil {
|
|
225
|
+
return nil
|
|
226
|
+
}
|
|
227
|
+
resolved = filepath.Clean(resolved)
|
|
228
|
+
seen := make(map[string]struct{})
|
|
229
|
+
for range 255 {
|
|
230
|
+
if _, exists := seen[resolved]; exists {
|
|
231
|
+
return nil
|
|
232
|
+
}
|
|
233
|
+
seen[resolved] = struct{}{}
|
|
234
|
+
if evaluated, evalErr := filepath.EvalSymlinks(resolved); evalErr == nil {
|
|
235
|
+
evaluated, evalErr = filepath.Abs(evaluated)
|
|
236
|
+
if evalErr != nil {
|
|
237
|
+
return nil
|
|
238
|
+
}
|
|
239
|
+
evaluated = filepath.Clean(evaluated)
|
|
240
|
+
if _, statErr := os.Stat(evaluated); statErr != nil {
|
|
241
|
+
return nil
|
|
242
|
+
}
|
|
243
|
+
return &evaluated
|
|
244
|
+
}
|
|
245
|
+
next, ok := stripResolveHostInputLinkAncestor(resolved)
|
|
246
|
+
if !ok {
|
|
247
|
+
return nil
|
|
248
|
+
}
|
|
249
|
+
resolved = next
|
|
250
|
+
}
|
|
251
|
+
return nil
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// stripResolveHostInputLinkAncestor follows the nearest link-like ancestor and
|
|
255
|
+
// reattaches its remaining suffix. Windows junction children can be opened and
|
|
256
|
+
// os.Readlink exposes the junction itself even when EvalSymlinks rejects the
|
|
257
|
+
// complete child path.
|
|
258
|
+
func stripResolveHostInputLinkAncestor(location string) (string, bool) {
|
|
259
|
+
probe := filepath.Clean(location)
|
|
260
|
+
suffix := make([]string, 0)
|
|
261
|
+
for {
|
|
262
|
+
if target, err := os.Readlink(probe); err == nil {
|
|
263
|
+
if !filepath.IsAbs(target) {
|
|
264
|
+
target = filepath.Join(filepath.Dir(probe), target)
|
|
265
|
+
}
|
|
266
|
+
for i := len(suffix) - 1; i >= 0; i-- {
|
|
267
|
+
target = filepath.Join(target, suffix[i])
|
|
268
|
+
}
|
|
269
|
+
absolute, absErr := filepath.Abs(target)
|
|
270
|
+
if absErr != nil {
|
|
271
|
+
return "", false
|
|
272
|
+
}
|
|
273
|
+
return filepath.Clean(absolute), true
|
|
274
|
+
}
|
|
275
|
+
parent := filepath.Dir(probe)
|
|
276
|
+
if parent == probe {
|
|
277
|
+
return "", false
|
|
278
|
+
}
|
|
279
|
+
suffix = append(suffix, filepath.Base(probe))
|
|
280
|
+
probe = parent
|
|
161
281
|
}
|
|
162
282
|
}
|
|
163
283
|
|
|
@@ -169,6 +289,10 @@ func loadStripJSONConfigFile(location string) (any, error) {
|
|
|
169
289
|
if err != nil {
|
|
170
290
|
return nil, fmt.Errorf("@ttsc/strip: read config file %s: %w", location, err)
|
|
171
291
|
}
|
|
292
|
+
return parseStripJSONConfigFile(location, body)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
func parseStripJSONConfigFile(location string, body []byte) (any, error) {
|
|
172
296
|
body = bytes.TrimPrefix(body, []byte{0xEF, 0xBB, 0xBF})
|
|
173
297
|
var out any
|
|
174
298
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
@@ -181,7 +305,201 @@ func loadStripJSONConfigFile(location string) (any, error) {
|
|
|
181
305
|
// loadStripScriptConfigFile to evaluate a .js/.cjs/.mjs strip config and
|
|
182
306
|
// serialize the result to stdout as JSON.
|
|
183
307
|
const stripScriptLoaderSource = `
|
|
184
|
-
const {
|
|
308
|
+
const { createRequire, isBuiltin, registerHooks } = require("node:module");
|
|
309
|
+
const crypto = require("node:crypto");
|
|
310
|
+
const fs = require("node:fs");
|
|
311
|
+
const path = require("node:path");
|
|
312
|
+
const { fileURLToPath, pathToFileURL } = require("node:url");
|
|
313
|
+
const inputs = new Set();
|
|
314
|
+
const hashes = new Map();
|
|
315
|
+
const realpaths = new Map();
|
|
316
|
+
const signatures = new Map();
|
|
317
|
+
const unstableHashes = new Set();
|
|
318
|
+
|
|
319
|
+
function existingFile(file) {
|
|
320
|
+
try { return fs.statSync(file).isFile(); }
|
|
321
|
+
catch { return false; }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function missingPathError(error) {
|
|
325
|
+
return error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function inputMetadataSignature(file) {
|
|
329
|
+
const requested = path.resolve(file);
|
|
330
|
+
let current = requested;
|
|
331
|
+
for (;;) {
|
|
332
|
+
try {
|
|
333
|
+
const link = fs.lstatSync(current, { bigint: true });
|
|
334
|
+
let target = link;
|
|
335
|
+
if (link.isSymbolicLink()) {
|
|
336
|
+
try { target = fs.statSync(current, { bigint: true }); }
|
|
337
|
+
catch { return undefined; }
|
|
338
|
+
}
|
|
339
|
+
return [path.relative(current, requested), link.dev, link.ino, link.mode, link.size, link.mtimeNs, link.ctimeNs, target.dev, target.ino, target.mode, target.size, target.mtimeNs, target.ctimeNs].join(":");
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (!missingPathError(error)) return undefined;
|
|
342
|
+
const parent = path.dirname(current);
|
|
343
|
+
if (parent === current) return undefined;
|
|
344
|
+
current = parent;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function recordInput(file) {
|
|
350
|
+
file = path.resolve(file);
|
|
351
|
+
inputs.add(file);
|
|
352
|
+
if (unstableHashes.has(file)) return;
|
|
353
|
+
const beforeSignature = inputMetadataSignature(file);
|
|
354
|
+
let observed;
|
|
355
|
+
let observedRealpath;
|
|
356
|
+
try { observed = fs.statSync(file).isDirectory() ? crypto.createHash("sha256").update("ttsc:host-input:directory\\0").digest("hex") : crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
357
|
+
catch { observed = null; }
|
|
358
|
+
try { observedRealpath = fs.realpathSync.native(file); }
|
|
359
|
+
catch { observedRealpath = null; }
|
|
360
|
+
const afterSignature = inputMetadataSignature(file);
|
|
361
|
+
if (beforeSignature === undefined || afterSignature === undefined || beforeSignature !== afterSignature || (signatures.has(file) && signatures.get(file) !== afterSignature) || (hashes.has(file) && hashes.get(file) !== observed) || (realpaths.has(file) && realpaths.get(file) !== observedRealpath)) {
|
|
362
|
+
hashes.delete(file);
|
|
363
|
+
realpaths.delete(file);
|
|
364
|
+
signatures.delete(file);
|
|
365
|
+
unstableHashes.add(file);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
signatures.set(file, afterSignature);
|
|
369
|
+
hashes.set(file, observed);
|
|
370
|
+
realpaths.set(file, observedRealpath);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function recordFile(file) {
|
|
374
|
+
const resolvedFile = path.resolve(file);
|
|
375
|
+
recordInput(resolvedFile);
|
|
376
|
+
for (let directory = path.dirname(resolvedFile);;) {
|
|
377
|
+
const manifest = path.join(directory, "package.json");
|
|
378
|
+
recordInput(manifest);
|
|
379
|
+
if (existingFile(manifest)) {
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
const parent = path.dirname(directory);
|
|
383
|
+
if (parent === directory) {
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
directory = parent;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function recordPackageManifests(file) {
|
|
391
|
+
for (let directory = path.dirname(path.resolve(file));;) {
|
|
392
|
+
const manifest = path.join(directory, "package.json");
|
|
393
|
+
recordInput(manifest);
|
|
394
|
+
if (existingFile(manifest)) return;
|
|
395
|
+
const parent = path.dirname(directory);
|
|
396
|
+
if (parent === directory) return;
|
|
397
|
+
directory = parent;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const moduleProbeExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"];
|
|
402
|
+
function moduleCandidates(base) {
|
|
403
|
+
return [
|
|
404
|
+
base,
|
|
405
|
+
...moduleProbeExtensions.map((extension) => base + extension),
|
|
406
|
+
path.join(base, "package.json"),
|
|
407
|
+
...moduleProbeExtensions.map((extension) => path.join(base, "index" + extension)),
|
|
408
|
+
];
|
|
409
|
+
}
|
|
410
|
+
const recordedModuleBases = new Set();
|
|
411
|
+
function recordManifestTargets(value, directory, allowBare = false) {
|
|
412
|
+
if (typeof value === "string") {
|
|
413
|
+
if (value !== "" && (allowBare || value.startsWith("./") || value.startsWith("../"))) recordModuleCandidates(path.resolve(directory, value));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (Array.isArray(value)) {
|
|
417
|
+
for (const item of value) recordManifestTargets(item, directory, allowBare);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (value && typeof value === "object") {
|
|
421
|
+
for (const item of Object.values(value)) recordManifestTargets(item, directory, allowBare);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function recordModuleCandidates(base) {
|
|
425
|
+
const resolvedBase = path.resolve(base);
|
|
426
|
+
if (recordedModuleBases.has(resolvedBase)) return;
|
|
427
|
+
recordedModuleBases.add(resolvedBase);
|
|
428
|
+
for (const candidate of moduleCandidates(resolvedBase)) recordInput(candidate);
|
|
429
|
+
try {
|
|
430
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(resolvedBase, "package.json"), "utf8").replace(/^\uFEFF/, ""));
|
|
431
|
+
recordManifestTargets(manifest.exports, resolvedBase);
|
|
432
|
+
recordManifestTargets(manifest.module, resolvedBase, true);
|
|
433
|
+
recordManifestTargets(manifest.main, resolvedBase, true);
|
|
434
|
+
} catch {}
|
|
435
|
+
}
|
|
436
|
+
function candidateSelected(base, resolvedFile) {
|
|
437
|
+
for (const candidate of moduleCandidates(base)) {
|
|
438
|
+
try {
|
|
439
|
+
const canonical = fs.realpathSync.native(candidate);
|
|
440
|
+
const relative = path.relative(canonical, resolvedFile);
|
|
441
|
+
if (relative === "" || (fs.statSync(canonical).isDirectory() && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) return true;
|
|
442
|
+
} catch {}
|
|
443
|
+
}
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
function localBases(specifier, parentDirectory) {
|
|
447
|
+
if (specifier.startsWith("file:")) return [fileURLToPath(specifier)];
|
|
448
|
+
const raw = path.resolve(parentDirectory, specifier);
|
|
449
|
+
const suffixStart = specifier.search(/[?#]/);
|
|
450
|
+
if (suffixStart === -1) return [raw];
|
|
451
|
+
const pathname = specifier.slice(0, suffixStart);
|
|
452
|
+
return pathname === "" ? [raw] : [...new Set([raw, path.resolve(parentDirectory, pathname)])];
|
|
453
|
+
}
|
|
454
|
+
function recordResolutionCandidates(specifier, parentURL, resolvedURL) {
|
|
455
|
+
if (typeof parentURL !== "string" || !parentURL.startsWith("file:")) return;
|
|
456
|
+
const parentDirectory = path.dirname(fileURLToPath(parentURL));
|
|
457
|
+
let resolvedFile;
|
|
458
|
+
try {
|
|
459
|
+
resolvedFile = typeof resolvedURL === "string" && resolvedURL.startsWith("file:")
|
|
460
|
+
? fs.realpathSync.native(fileURLToPath(resolvedURL))
|
|
461
|
+
: undefined;
|
|
462
|
+
} catch {}
|
|
463
|
+
if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("file:")) {
|
|
464
|
+
try {
|
|
465
|
+
for (const base of localBases(specifier, parentDirectory)) {
|
|
466
|
+
recordPackageManifests(base);
|
|
467
|
+
let exact = false;
|
|
468
|
+
try { exact = resolvedFile === undefined ? fs.statSync(base).isFile() : fs.realpathSync.native(base) === resolvedFile; } catch {}
|
|
469
|
+
if (exact) recordInput(base);
|
|
470
|
+
else recordModuleCandidates(base);
|
|
471
|
+
}
|
|
472
|
+
} catch {}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (isBuiltin(specifier) || specifier.startsWith("#")) return;
|
|
476
|
+
const parts = specifier.split("/");
|
|
477
|
+
const packageParts = parts[0].startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1);
|
|
478
|
+
if (packageParts.some((part) => part === undefined || part === "")) return;
|
|
479
|
+
const packageName = packageParts.join("/");
|
|
480
|
+
const subpath = parts.slice(packageParts.length);
|
|
481
|
+
const searchPaths = createRequire(parentURL).resolve.paths(specifier) ?? [];
|
|
482
|
+
for (const searchPath of searchPaths) {
|
|
483
|
+
const packageDirectory = path.join(searchPath, packageName);
|
|
484
|
+
recordModuleCandidates(packageDirectory);
|
|
485
|
+
if (subpath.length !== 0) recordModuleCandidates(path.join(packageDirectory, ...subpath));
|
|
486
|
+
if (resolvedFile !== undefined && candidateSelected(packageDirectory, resolvedFile)) break;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
recordFile(process.argv[1]);
|
|
491
|
+
registerHooks({
|
|
492
|
+
resolve(specifier, context, nextResolve) {
|
|
493
|
+
recordResolutionCandidates(specifier, context.parentURL, undefined);
|
|
494
|
+
const resolved = nextResolve(specifier, context);
|
|
495
|
+
const url = typeof resolved === "string" ? resolved : resolved && resolved.url;
|
|
496
|
+
recordResolutionCandidates(specifier, context.parentURL, url);
|
|
497
|
+
if (typeof url === "string" && url.startsWith("file:")) {
|
|
498
|
+
recordFile(fileURLToPath(url));
|
|
499
|
+
}
|
|
500
|
+
return resolved;
|
|
501
|
+
},
|
|
502
|
+
});
|
|
185
503
|
|
|
186
504
|
(async () => {
|
|
187
505
|
const mod = await import(pathToFileURL(process.argv[1]).href);
|
|
@@ -197,15 +515,11 @@ const { pathToFileURL } = require("node:url");
|
|
|
197
515
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
198
516
|
throw new Error("strip config file must export an object");
|
|
199
517
|
}
|
|
200
|
-
|
|
518
|
+
const serializedValue = JSON.stringify(value);
|
|
519
|
+
for (const input of [...inputs]) recordInput(input);
|
|
520
|
+
process.stdout.write(JSON.stringify({ value: JSON.parse(serializedValue), hashes: Object.fromEntries(hashes), inputs: [...inputs].sort(), realpaths: Object.fromEntries(realpaths) }));
|
|
201
521
|
})().catch((error) => {
|
|
202
522
|
process.stderr.write(error && error.stack ? error.stack : String(error));
|
|
203
|
-
// The stack above is for the reader. This is for the caller: the parent reads
|
|
204
|
-
// stdout as the payload channel either way, so a failure reason travels as
|
|
205
|
-
// data rather than as text scraped back out of a captured stream. The exit
|
|
206
|
-
// code is set before the write so a callback that never fires still fails the
|
|
207
|
-
// load, and the write's completion is what triggers the exit, because
|
|
208
|
-
// process.exit abandons a pending pipe write.
|
|
209
523
|
process.exitCode = 1;
|
|
210
524
|
process.stdout.write(JSON.stringify({ __ttscLoaderError: error && error.message ? String(error.message) : String(error) }), () => process.exit(1));
|
|
211
525
|
});
|
|
@@ -215,13 +529,26 @@ const { pathToFileURL } = require("node:url");
|
|
|
215
529
|
// Node subprocess that dynamic-imports the file, resolves the default export,
|
|
216
530
|
// and serializes the result as JSON to stdout.
|
|
217
531
|
func loadStripScriptConfigFile(location string) (any, error) {
|
|
532
|
+
loaded, err := loadStripScriptConfigFileWithInputs(location)
|
|
533
|
+
return loaded.value, err
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
func loadStripScriptConfigFileWithInputs(location string) (stripLoadedConfig, error) {
|
|
218
537
|
node := os.Getenv("TTSC_NODE_BINARY")
|
|
219
538
|
if node == "" {
|
|
220
539
|
node = "node"
|
|
221
540
|
}
|
|
222
541
|
ctx, cancel := context.WithCancel(context.Background())
|
|
223
542
|
defer cancel()
|
|
224
|
-
|
|
543
|
+
// Windows limits the whole process command line to roughly 32 KiB. The
|
|
544
|
+
// dependency-tracking loader is intentionally larger than that, so keep only
|
|
545
|
+
// an explicit CommonJS stdin program and remove Node's stdin sentinel before
|
|
546
|
+
// the loader runs. This preserves the historical process.argv layout seen by
|
|
547
|
+
// both the loader and the imported user config without using string eval.
|
|
548
|
+
cmd := exec.CommandContext(ctx, node, "--input-type=commonjs", "-", location)
|
|
549
|
+
cmd.Stdin = strings.NewReader(
|
|
550
|
+
"process.argv.splice(1, 1);\n" + stripScriptLoaderSource,
|
|
551
|
+
)
|
|
225
552
|
cmd.Env = stripNodeConfigLoaderEnv(location)
|
|
226
553
|
// The child's stderr is human output and goes straight to this process's
|
|
227
554
|
// stderr as it is written. Collecting it only to replay it afterwards is what
|
|
@@ -234,15 +561,46 @@ func loadStripScriptConfigFile(location string) (any, error) {
|
|
|
234
561
|
// What it could not put there is a reason a caller can act on, so that
|
|
235
562
|
// arrives through the payload channel instead.
|
|
236
563
|
if reason := loaderFailureReason(output); reason != "" {
|
|
237
|
-
return
|
|
564
|
+
return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: load config file %s: %s", location, reason)
|
|
238
565
|
}
|
|
239
|
-
return
|
|
566
|
+
return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: load config file %s: %w", location, err)
|
|
240
567
|
}
|
|
241
|
-
|
|
242
|
-
if err
|
|
243
|
-
return
|
|
568
|
+
loaded, err := decodeStripConfigLoaderOutput(output)
|
|
569
|
+
if err != nil {
|
|
570
|
+
return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: parse config file %s output: %w", location, err)
|
|
244
571
|
}
|
|
245
|
-
return
|
|
572
|
+
return loaded, nil
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
func decodeStripConfigLoaderOutput(output []byte) (stripLoadedConfig, error) {
|
|
576
|
+
var envelope struct {
|
|
577
|
+
Error string `json:"__ttscLoaderError"`
|
|
578
|
+
Hashes map[string]*string `json:"hashes"`
|
|
579
|
+
Inputs []string `json:"inputs"`
|
|
580
|
+
Realpaths map[string]*string `json:"realpaths"`
|
|
581
|
+
Value json.RawMessage `json:"value"`
|
|
582
|
+
}
|
|
583
|
+
if err := json.Unmarshal(output, &envelope); err != nil {
|
|
584
|
+
return stripLoadedConfig{}, err
|
|
585
|
+
}
|
|
586
|
+
if envelope.Error != "" {
|
|
587
|
+
return stripLoadedConfig{}, fmt.Errorf("%s", envelope.Error)
|
|
588
|
+
}
|
|
589
|
+
if len(envelope.Value) == 0 {
|
|
590
|
+
// Test/fallback launchers written against the historical payload return
|
|
591
|
+
// the config value directly. Preserve that accepted contract while real
|
|
592
|
+
// loaders use the envelope to carry runtime inputs.
|
|
593
|
+
var value any
|
|
594
|
+
if err := json.Unmarshal(output, &value); err != nil {
|
|
595
|
+
return stripLoadedConfig{}, err
|
|
596
|
+
}
|
|
597
|
+
return stripLoadedConfig{value: value}, nil
|
|
598
|
+
}
|
|
599
|
+
var value any
|
|
600
|
+
if err := json.Unmarshal(envelope.Value, &value); err != nil {
|
|
601
|
+
return stripLoadedConfig{}, err
|
|
602
|
+
}
|
|
603
|
+
return stripLoadedConfig{hashes: envelope.Hashes, inputs: envelope.Inputs, realpaths: envelope.Realpaths, value: value}, nil
|
|
246
604
|
}
|
|
247
605
|
|
|
248
606
|
// stripTypeScriptLoaderSource returns the TypeScript source of the ephemeral
|
|
@@ -250,7 +608,217 @@ func loadStripScriptConfigFile(location string) (any, error) {
|
|
|
250
608
|
// importLiteral must be a JSON-encoded relative import path (e.g.
|
|
251
609
|
// `"./strip.config.ts"`) produced by json.Marshal.
|
|
252
610
|
func stripTypeScriptLoaderSource(importLiteral string) string {
|
|
253
|
-
return fmt.Sprintf(
|
|
611
|
+
return fmt.Sprintf(`// @ts-nocheck
|
|
612
|
+
import { createRequire, isBuiltin, registerHooks } from "node:module";
|
|
613
|
+
import crypto from "node:crypto";
|
|
614
|
+
import fs from "node:fs";
|
|
615
|
+
import path from "node:path";
|
|
616
|
+
import { fileURLToPath } from "node:url";
|
|
617
|
+
|
|
618
|
+
const inputs = new Set<string>();
|
|
619
|
+
const hashes = new Map<string, string | null>();
|
|
620
|
+
const realpaths = new Map<string, string | null>();
|
|
621
|
+
const signatures = new Map<string, string>();
|
|
622
|
+
const unstableHashes = new Set<string>();
|
|
623
|
+
|
|
624
|
+
function existingFile(file: string): boolean {
|
|
625
|
+
try { return fs.statSync(file).isFile(); }
|
|
626
|
+
catch { return false; }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function missingPathError(error: unknown): boolean {
|
|
630
|
+
const code = (error as { code?: unknown } | undefined)?.code;
|
|
631
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function inputMetadataSignature(file: string): string | undefined {
|
|
635
|
+
const requested = path.resolve(file);
|
|
636
|
+
let current = requested;
|
|
637
|
+
for (;;) {
|
|
638
|
+
try {
|
|
639
|
+
const link = fs.lstatSync(current, { bigint: true });
|
|
640
|
+
let target = link;
|
|
641
|
+
if (link.isSymbolicLink()) {
|
|
642
|
+
try { target = fs.statSync(current, { bigint: true }); }
|
|
643
|
+
catch { return undefined; }
|
|
644
|
+
}
|
|
645
|
+
return [path.relative(current, requested), link.dev, link.ino, link.mode, link.size, link.mtimeNs, link.ctimeNs, target.dev, target.ino, target.mode, target.size, target.mtimeNs, target.ctimeNs].join(":");
|
|
646
|
+
} catch (error) {
|
|
647
|
+
if (!missingPathError(error)) return undefined;
|
|
648
|
+
const parent = path.dirname(current);
|
|
649
|
+
if (parent === current) return undefined;
|
|
650
|
+
current = parent;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function recordInput(file: string): void {
|
|
656
|
+
file = path.resolve(file);
|
|
657
|
+
inputs.add(file);
|
|
658
|
+
if (unstableHashes.has(file)) return;
|
|
659
|
+
const beforeSignature = inputMetadataSignature(file);
|
|
660
|
+
let observed: string | null;
|
|
661
|
+
let observedRealpath: string | null;
|
|
662
|
+
try { observed = fs.statSync(file).isDirectory() ? crypto.createHash("sha256").update("ttsc:host-input:directory\\0").digest("hex") : crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
663
|
+
catch { observed = null; }
|
|
664
|
+
try { observedRealpath = fs.realpathSync.native(file); }
|
|
665
|
+
catch { observedRealpath = null; }
|
|
666
|
+
const afterSignature = inputMetadataSignature(file);
|
|
667
|
+
if (beforeSignature === undefined || afterSignature === undefined || beforeSignature !== afterSignature || (signatures.has(file) && signatures.get(file) !== afterSignature) || (hashes.has(file) && hashes.get(file) !== observed) || (realpaths.has(file) && realpaths.get(file) !== observedRealpath)) {
|
|
668
|
+
hashes.delete(file);
|
|
669
|
+
realpaths.delete(file);
|
|
670
|
+
signatures.delete(file);
|
|
671
|
+
unstableHashes.add(file);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
signatures.set(file, afterSignature);
|
|
675
|
+
hashes.set(file, observed);
|
|
676
|
+
realpaths.set(file, observedRealpath);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function recordFile(file: string): void {
|
|
680
|
+
const resolvedFile = path.resolve(file);
|
|
681
|
+
recordInput(resolvedFile);
|
|
682
|
+
for (let directory = path.dirname(resolvedFile);;) {
|
|
683
|
+
const manifest = path.join(directory, "package.json");
|
|
684
|
+
recordInput(manifest);
|
|
685
|
+
if (existingFile(manifest)) {
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
const parent = path.dirname(directory);
|
|
689
|
+
if (parent === directory) {
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
directory = parent;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function recordPackageManifests(file: string): void {
|
|
697
|
+
for (let directory = path.dirname(path.resolve(file));;) {
|
|
698
|
+
const manifest = path.join(directory, "package.json");
|
|
699
|
+
recordInput(manifest);
|
|
700
|
+
if (existingFile(manifest)) return;
|
|
701
|
+
const parent = path.dirname(directory);
|
|
702
|
+
if (parent === directory) return;
|
|
703
|
+
directory = parent;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const moduleProbeExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"] as const;
|
|
708
|
+
const jsToTsProbeExtensions = new Map<string, readonly string[]>([
|
|
709
|
+
[".js", [".ts", ".tsx"]],
|
|
710
|
+
[".jsx", [".tsx"]],
|
|
711
|
+
[".mjs", [".mts"]],
|
|
712
|
+
[".cjs", [".cts"]],
|
|
713
|
+
]);
|
|
714
|
+
function sourceSubstitutionCandidates(base: string): string[] {
|
|
715
|
+
const extension = path.extname(base).toLowerCase();
|
|
716
|
+
const substitutions = jsToTsProbeExtensions.get(extension);
|
|
717
|
+
if (substitutions === undefined) return [];
|
|
718
|
+
const stem = base.slice(0, base.length - extension.length);
|
|
719
|
+
return substitutions.map((candidate) => stem + candidate);
|
|
720
|
+
}
|
|
721
|
+
function moduleCandidates(base: string): string[] {
|
|
722
|
+
return [
|
|
723
|
+
base,
|
|
724
|
+
...sourceSubstitutionCandidates(base),
|
|
725
|
+
...moduleProbeExtensions.map((extension) => base + extension),
|
|
726
|
+
path.join(base, "package.json"),
|
|
727
|
+
...moduleProbeExtensions.map((extension) => path.join(base, "index" + extension)),
|
|
728
|
+
];
|
|
729
|
+
}
|
|
730
|
+
const recordedModuleBases = new Set<string>();
|
|
731
|
+
function recordManifestTargets(value: unknown, directory: string, allowBare: boolean = false): void {
|
|
732
|
+
if (typeof value === "string") {
|
|
733
|
+
if (value !== "" && (allowBare || value.startsWith("./") || value.startsWith("../"))) recordModuleCandidates(path.resolve(directory, value));
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (Array.isArray(value)) {
|
|
737
|
+
for (const item of value) recordManifestTargets(item, directory, allowBare);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (value !== null && typeof value === "object") {
|
|
741
|
+
for (const item of Object.values(value)) recordManifestTargets(item, directory, allowBare);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function recordModuleCandidates(base: string): void {
|
|
745
|
+
const resolvedBase = path.resolve(base);
|
|
746
|
+
if (recordedModuleBases.has(resolvedBase)) return;
|
|
747
|
+
recordedModuleBases.add(resolvedBase);
|
|
748
|
+
for (const candidate of moduleCandidates(resolvedBase)) recordInput(candidate);
|
|
749
|
+
try {
|
|
750
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(resolvedBase, "package.json"), "utf8").replace(/^\uFEFF/, ""));
|
|
751
|
+
recordManifestTargets(manifest.exports, resolvedBase);
|
|
752
|
+
recordManifestTargets(manifest.module, resolvedBase, true);
|
|
753
|
+
recordManifestTargets(manifest.main, resolvedBase, true);
|
|
754
|
+
} catch {}
|
|
755
|
+
}
|
|
756
|
+
function candidateSelected(base: string, resolvedFile: string): boolean {
|
|
757
|
+
for (const candidate of moduleCandidates(base)) {
|
|
758
|
+
try {
|
|
759
|
+
const canonical = fs.realpathSync.native(candidate);
|
|
760
|
+
const relative = path.relative(canonical, resolvedFile);
|
|
761
|
+
if (relative === "" || (fs.statSync(canonical).isDirectory() && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) return true;
|
|
762
|
+
} catch {}
|
|
763
|
+
}
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
function localBases(specifier: string, parentDirectory: string): string[] {
|
|
767
|
+
if (specifier.startsWith("file:")) return [fileURLToPath(specifier)];
|
|
768
|
+
const raw = path.resolve(parentDirectory, specifier);
|
|
769
|
+
const suffixStart = specifier.search(/[?#]/);
|
|
770
|
+
if (suffixStart === -1) return [raw];
|
|
771
|
+
const pathname = specifier.slice(0, suffixStart);
|
|
772
|
+
return pathname === "" ? [raw] : [...new Set([raw, path.resolve(parentDirectory, pathname)])];
|
|
773
|
+
}
|
|
774
|
+
function recordResolutionCandidates(specifier: string, parentURL: string | undefined, resolvedURL: string | undefined): void {
|
|
775
|
+
if (typeof parentURL !== "string" || !parentURL.startsWith("file:")) return;
|
|
776
|
+
const parentDirectory = path.dirname(fileURLToPath(parentURL));
|
|
777
|
+
let resolvedFile: string | undefined;
|
|
778
|
+
try {
|
|
779
|
+
resolvedFile = typeof resolvedURL === "string" && resolvedURL.startsWith("file:")
|
|
780
|
+
? fs.realpathSync.native(fileURLToPath(resolvedURL))
|
|
781
|
+
: undefined;
|
|
782
|
+
} catch {}
|
|
783
|
+
if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("file:")) {
|
|
784
|
+
try {
|
|
785
|
+
for (const base of localBases(specifier, parentDirectory)) {
|
|
786
|
+
recordPackageManifests(base);
|
|
787
|
+
let exact = false;
|
|
788
|
+
try { exact = resolvedFile === undefined ? fs.statSync(base).isFile() : fs.realpathSync.native(base) === resolvedFile; } catch {}
|
|
789
|
+
if (exact) recordInput(base);
|
|
790
|
+
else recordModuleCandidates(base);
|
|
791
|
+
}
|
|
792
|
+
} catch {}
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (isBuiltin(specifier) || specifier.startsWith("#")) return;
|
|
796
|
+
const parts = specifier.split("/");
|
|
797
|
+
const packageParts = parts[0]!.startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1);
|
|
798
|
+
if (packageParts.some((part) => part === undefined || part === "")) return;
|
|
799
|
+
const packageName = packageParts.join("/");
|
|
800
|
+
const subpath = parts.slice(packageParts.length);
|
|
801
|
+
const searchPaths = createRequire(parentURL).resolve.paths(specifier) ?? [];
|
|
802
|
+
for (const searchPath of searchPaths) {
|
|
803
|
+
const packageDirectory = path.join(searchPath, packageName);
|
|
804
|
+
recordModuleCandidates(packageDirectory);
|
|
805
|
+
if (subpath.length !== 0) recordModuleCandidates(path.join(packageDirectory, ...subpath));
|
|
806
|
+
if (resolvedFile !== undefined && candidateSelected(packageDirectory, resolvedFile)) break;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
registerHooks({
|
|
811
|
+
resolve(specifier, context, nextResolve) {
|
|
812
|
+
recordResolutionCandidates(specifier, context.parentURL, undefined);
|
|
813
|
+
const resolved = nextResolve(specifier, context);
|
|
814
|
+
const url = typeof resolved === "string" ? resolved : resolved?.url;
|
|
815
|
+
recordResolutionCandidates(specifier, context.parentURL, url);
|
|
816
|
+
if (typeof url === "string" && url.startsWith("file:")) {
|
|
817
|
+
recordFile(fileURLToPath(url));
|
|
818
|
+
}
|
|
819
|
+
return resolved;
|
|
820
|
+
},
|
|
821
|
+
});
|
|
254
822
|
|
|
255
823
|
declare const process: {
|
|
256
824
|
exitCode?: number;
|
|
@@ -266,6 +834,7 @@ declare const process: {
|
|
|
266
834
|
// left for a trailing handler to settle.
|
|
267
835
|
(async () => {
|
|
268
836
|
try {
|
|
837
|
+
const importedConfig = await import(%s);
|
|
269
838
|
let current: unknown = importedConfig;
|
|
270
839
|
for (let i = 0; i < 8; i++) {
|
|
271
840
|
if (current !== null && typeof current === "object" && Object.prototype.hasOwnProperty.call(current as Record<string, unknown>, "default")) {
|
|
@@ -280,7 +849,9 @@ declare const process: {
|
|
|
280
849
|
if (current === null || typeof current !== "object" || Array.isArray(current)) {
|
|
281
850
|
throw new Error("strip config file must export an object");
|
|
282
851
|
}
|
|
283
|
-
|
|
852
|
+
const serializedValue = JSON.stringify(current);
|
|
853
|
+
for (const input of [...inputs]) recordInput(input);
|
|
854
|
+
process.stdout.write(JSON.stringify({ value: JSON.parse(serializedValue), hashes: Object.fromEntries(hashes), inputs: [...inputs].sort(), realpaths: Object.fromEntries(realpaths) }));
|
|
284
855
|
} catch (error) {
|
|
285
856
|
process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
|
|
286
857
|
// The stack above is for the reader. This is for the caller: the parent
|
|
@@ -307,32 +878,41 @@ declare const process: {
|
|
|
307
878
|
// type-check and execute the strip config file, so loading the host
|
|
308
879
|
// project's transform/check plugins would be wasteful and could fail the
|
|
309
880
|
// build against this deliberately lenient loader tsconfig.
|
|
310
|
-
|
|
881
|
+
//
|
|
882
|
+
// Both tools this spawns — the launcher and the compiler handed to it — are
|
|
883
|
+
// resolved from the project rather than from the process environment alone;
|
|
884
|
+
// see stripConfigToolAnchors.
|
|
885
|
+
func loadStripTypeScriptConfigFile(location, resolutionRoot string) (any, error) {
|
|
886
|
+
loaded, err := loadStripTypeScriptConfigFileWithInputs(location, resolutionRoot)
|
|
887
|
+
return loaded.value, err
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
func loadStripTypeScriptConfigFileWithInputs(location, resolutionRoot string) (stripLoadedConfig, error) {
|
|
311
891
|
tempDir, err := os.MkdirTemp(stripLoaderTempBase(location, os.TempDir()), "ttsc-strip-config-")
|
|
312
892
|
if err != nil {
|
|
313
|
-
return
|
|
893
|
+
return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: create config loader tempdir: %w", err)
|
|
314
894
|
}
|
|
315
895
|
defer os.RemoveAll(tempDir)
|
|
316
896
|
|
|
317
897
|
if err := stripLinkNearestNodeModules(tempDir, filepath.Dir(location)); err != nil {
|
|
318
|
-
return
|
|
898
|
+
return stripLoadedConfig{}, err
|
|
319
899
|
}
|
|
320
900
|
|
|
321
901
|
loader := filepath.Join(tempDir, "loader.mts")
|
|
322
902
|
tsconfig := filepath.Join(tempDir, "tsconfig.json")
|
|
323
903
|
importSpecifier, err := stripRelativeImportSpecifier(tempDir, location)
|
|
324
904
|
if err != nil {
|
|
325
|
-
return
|
|
905
|
+
return stripLoadedConfig{}, err
|
|
326
906
|
}
|
|
327
907
|
importLiteral, err := json.Marshal(importSpecifier)
|
|
328
908
|
if err != nil {
|
|
329
|
-
return
|
|
909
|
+
return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: encode config import %s: %w", location, err)
|
|
330
910
|
}
|
|
331
911
|
if err := os.WriteFile(loader, []byte(stripTypeScriptLoaderSource(string(importLiteral))), 0o644); err != nil {
|
|
332
|
-
return
|
|
912
|
+
return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: write config loader: %w", err)
|
|
333
913
|
}
|
|
334
914
|
if err := os.WriteFile(tsconfig, []byte(stripTypeScriptLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
|
|
335
|
-
return
|
|
915
|
+
return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: write config loader tsconfig: %w", err)
|
|
336
916
|
}
|
|
337
917
|
|
|
338
918
|
args := []string{
|
|
@@ -341,14 +921,15 @@ func loadStripTypeScriptConfigFile(location string) (any, error) {
|
|
|
341
921
|
"--cache-dir", filepath.Join(tempDir, "cache"),
|
|
342
922
|
"--no-plugins",
|
|
343
923
|
}
|
|
344
|
-
|
|
924
|
+
anchors := stripConfigToolAnchors(location, resolutionRoot)
|
|
925
|
+
if tsgo := stripResolveConfigTsgo(anchors); tsgo != "" {
|
|
345
926
|
args = append(args, "--binary", tsgo)
|
|
346
927
|
}
|
|
347
928
|
args = append(args, loader)
|
|
348
929
|
|
|
349
930
|
ctx, cancel := context.WithCancel(context.Background())
|
|
350
931
|
defer cancel()
|
|
351
|
-
cmd := stripTtsxCommandContext(ctx, args...)
|
|
932
|
+
cmd := stripTtsxCommandContext(ctx, anchors, args...)
|
|
352
933
|
cmd.Env = stripNodeConfigLoaderEnv(location)
|
|
353
934
|
// The child's stderr is human output and goes straight to this process's
|
|
354
935
|
// stderr as it is written. Collecting it only to replay it afterwards is what
|
|
@@ -361,15 +942,15 @@ func loadStripTypeScriptConfigFile(location string) (any, error) {
|
|
|
361
942
|
// What it could not put there is a reason a caller can act on, so that
|
|
362
943
|
// arrives through the payload channel instead.
|
|
363
944
|
if reason := loaderFailureReason(output); reason != "" {
|
|
364
|
-
return
|
|
945
|
+
return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %s", location, reason)
|
|
365
946
|
}
|
|
366
|
-
return
|
|
947
|
+
return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %w", location, err)
|
|
367
948
|
}
|
|
368
|
-
|
|
369
|
-
if err
|
|
370
|
-
return
|
|
949
|
+
loaded, err := decodeStripConfigLoaderOutput(output)
|
|
950
|
+
if err != nil {
|
|
951
|
+
return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: parse TypeScript config file %s output: %w", location, err)
|
|
371
952
|
}
|
|
372
|
-
return
|
|
953
|
+
return loaded, nil
|
|
373
954
|
}
|
|
374
955
|
|
|
375
956
|
// stripTypeScriptLoaderTsconfig generates the JSON content of the ephemeral
|
|
@@ -388,6 +969,7 @@ func stripTypeScriptLoaderTsconfig(loader, location, outDir string) string {
|
|
|
388
969
|
// resolving either way.
|
|
389
970
|
"module": stripConfigModuleOption(location),
|
|
390
971
|
"moduleResolution": "bundler",
|
|
972
|
+
"jsx": "preserve",
|
|
391
973
|
"noImplicitAny": false,
|
|
392
974
|
"outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
|
|
393
975
|
"rewriteRelativeImportExtensions": true,
|
|
@@ -414,6 +996,23 @@ func stripTypeScriptLoaderTsconfig(loader, location, outDir string) string {
|
|
|
414
996
|
return string(body)
|
|
415
997
|
}
|
|
416
998
|
|
|
999
|
+
// ttsc:config-loader-shared begin
|
|
1000
|
+
//
|
|
1001
|
+
// One policy in three Go copies: everything between these markers is
|
|
1002
|
+
// duplicated verbatim in packages/lint/linthost/config.go,
|
|
1003
|
+
// packages/banner/driver/banner.go and packages/strip/driver/config.go. #1169
|
|
1004
|
+
// decided against extracting it — the only home the three modules could share
|
|
1005
|
+
// is the public `packages/ttsc/driver` seam, and packages/lint's go.mod
|
|
1006
|
+
// deliberately requires no in-tree ttsc module — and replaced the checklist
|
|
1007
|
+
// with a gate: `scripts/ci/config-loader-copies.cjs` compares every function
|
|
1008
|
+
// between these markers across all three copies on every pull request, so
|
|
1009
|
+
// editing one and not the others fails by name. That file's header carries the
|
|
1010
|
+
// full decision and the rules for changing this block.
|
|
1011
|
+
//
|
|
1012
|
+
// The code between the markers must stay identical. Comments may differ, the
|
|
1013
|
+
// `@ttsc/<pkg>:` error prefix may differ, and @ttsc/strip spells each name with
|
|
1014
|
+
// a `strip` prefix. Anything package-specific belongs outside the markers.
|
|
1015
|
+
|
|
417
1016
|
// stripConfigModuleOption returns the loader tsconfig's "module" for a config
|
|
418
1017
|
// file: the module kind Node itself would give that file.
|
|
419
1018
|
//
|
|
@@ -536,13 +1135,233 @@ func stripResolveDirLink(dir string) string {
|
|
|
536
1135
|
return dir
|
|
537
1136
|
}
|
|
538
1137
|
|
|
1138
|
+
// stripRealpathIfPossible resolves location through its symlinks, and returns
|
|
1139
|
+
// it unchanged when it cannot be evaluated (a path that does not exist, or an
|
|
1140
|
+
// NTFS junction filepath.EvalSymlinks refuses to traverse).
|
|
1141
|
+
func stripRealpathIfPossible(location string) string {
|
|
1142
|
+
real, err := filepath.EvalSymlinks(location)
|
|
1143
|
+
if err != nil {
|
|
1144
|
+
return location
|
|
1145
|
+
}
|
|
1146
|
+
return real
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Both tools the TypeScript config evaluator needs — the `ttsx` launcher it
|
|
1150
|
+
// spawns and the native compiler it hands that launcher — are resolved from
|
|
1151
|
+
// the project being compiled, with an explicit environment variable winning
|
|
1152
|
+
// and a last resort that invents no path.
|
|
1153
|
+
//
|
|
1154
|
+
// The three Go copies are held identical by the gate named at the top of this
|
|
1155
|
+
// block. The JS original — `resolveConfigTsgo` / `resolveTtsxLauncher` in
|
|
1156
|
+
// packages/lint/src/index.ts — is a fourth copy in another language that no Go
|
|
1157
|
+
// gate can reach; what it owes is that both policies stay describable in one
|
|
1158
|
+
// sentence.
|
|
1159
|
+
//
|
|
1160
|
+
// The environment alone is the wrong place to ask. `ttsx` exports
|
|
1161
|
+
// TTSC_TSGO_BINARY and TTSC_TTSX_BINARY to its own descendants, so a host
|
|
1162
|
+
// launched under `ttsx` inherited both and a host launched any other way
|
|
1163
|
+
// inherited neither. The shipped `ttscserver` binary invoked with its
|
|
1164
|
+
// documented `--tsgo <path>` flag keeps that path in a local and exports
|
|
1165
|
+
// nothing, and an embedder of the driver package exports nothing either. For
|
|
1166
|
+
// those the evaluator spawned a bare `ttsx` that only a global install puts on
|
|
1167
|
+
// PATH, and, past that, a compiler-less child that aborted with
|
|
1168
|
+
// `ttsc: typescript is required` before a line of the config was read.
|
|
1169
|
+
//
|
|
1170
|
+
// stripConfigToolAnchors lists the file paths those resolutions walk upward
|
|
1171
|
+
// from, in order: the config file being evaluated, then the resolution root's
|
|
1172
|
+
// manifest. The config comes first because it is the file whose own
|
|
1173
|
+
// installation decides which toolchain the config's imports were written
|
|
1174
|
+
// against; the resolution root answers for a config that lives outside the
|
|
1175
|
+
// project tree (a `configFile` pointed at a shared package), and for one
|
|
1176
|
+
// discovered above a workspace that installs its own toolchain.
|
|
1177
|
+
func stripConfigToolAnchors(configPath, resolutionRoot string) []string {
|
|
1178
|
+
anchors := make([]string, 0, 2)
|
|
1179
|
+
if strings.TrimSpace(configPath) != "" {
|
|
1180
|
+
anchors = append(anchors, configPath)
|
|
1181
|
+
}
|
|
1182
|
+
if strings.TrimSpace(resolutionRoot) != "" {
|
|
1183
|
+
anchors = append(anchors, filepath.Join(resolutionRoot, "package.json"))
|
|
1184
|
+
}
|
|
1185
|
+
return anchors
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// stripResolveConfigTsgo returns the native TypeScript compiler the evaluator
|
|
1189
|
+
// hands its ttsx child through `--binary`, or "" to leave the child resolving
|
|
1190
|
+
// for itself.
|
|
1191
|
+
//
|
|
1192
|
+
// The child runs with `--cwd <ephemeral loader dir>`, so it cannot discover
|
|
1193
|
+
// `typescript` the way an ordinary invocation does: stripLinkNearestNodeModules
|
|
1194
|
+
// is the only thing that puts the project's modules within its reach, and it
|
|
1195
|
+
// links nothing when the config's ancestry carries no node_modules. An explicit
|
|
1196
|
+
// TTSC_TSGO_BINARY still wins, so an embedder that pins a compiler keeps
|
|
1197
|
+
// pinning it. "" is the unchanged last resort: a project that cannot answer
|
|
1198
|
+
// here could not answer inside the child either, and the child's own diagnostic
|
|
1199
|
+
// is the one that names the missing package.
|
|
1200
|
+
func stripResolveConfigTsgo(anchors []string) string {
|
|
1201
|
+
if explicit := strings.TrimSpace(os.Getenv("TTSC_TSGO_BINARY")); explicit != "" {
|
|
1202
|
+
return explicit
|
|
1203
|
+
}
|
|
1204
|
+
for _, anchor := range anchors {
|
|
1205
|
+
if binary := stripTsgoBinaryFrom(anchor); binary != "" {
|
|
1206
|
+
return binary
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
return ""
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// stripTsgoBinaryFrom returns the platform compiler executable of the
|
|
1213
|
+
// `typescript` install `anchor` can see, or "" when this anchor reaches neither
|
|
1214
|
+
// the package nor its platform dependency.
|
|
1215
|
+
//
|
|
1216
|
+
// Mirrors resolveTsgo.ts so the Go plugin and the JS launcher name one file:
|
|
1217
|
+
// the `typescript` manifest, then `@typescript/typescript-<platform>-<arch>`
|
|
1218
|
+
// resolved from that manifest, then `lib/tsc` inside it.
|
|
1219
|
+
//
|
|
1220
|
+
// The install is chased to its real directory before the second hop, because
|
|
1221
|
+
// Node resolves a module's own dependencies from its real location. pnpm keeps
|
|
1222
|
+
// the real `typescript` directory in its content-addressed store with the
|
|
1223
|
+
// platform package beside it and leaves a link in the project's node_modules,
|
|
1224
|
+
// so a walk that started at the link would climb straight past the platform
|
|
1225
|
+
// package. NTFS junctions defeat filepath.EvalSymlinks, so the link component
|
|
1226
|
+
// is chased by hand first, the same order stripLoaderTempBase uses.
|
|
1227
|
+
func stripTsgoBinaryFrom(anchor string) string {
|
|
1228
|
+
manifest := stripNodePackageManifestFrom(anchor, "typescript")
|
|
1229
|
+
if manifest == "" {
|
|
1230
|
+
return ""
|
|
1231
|
+
}
|
|
1232
|
+
packageDir := stripRealpathIfPossible(stripResolveDirLink(filepath.Dir(manifest)))
|
|
1233
|
+
platform, arch := stripNodePlatformPair()
|
|
1234
|
+
platformManifest := stripNodePackageManifestFrom(
|
|
1235
|
+
filepath.Join(packageDir, "package.json"),
|
|
1236
|
+
"@typescript/typescript-"+platform+"-"+arch,
|
|
1237
|
+
)
|
|
1238
|
+
if platformManifest == "" {
|
|
1239
|
+
return ""
|
|
1240
|
+
}
|
|
1241
|
+
name := "tsc"
|
|
1242
|
+
if runtime.GOOS == "windows" {
|
|
1243
|
+
name = "tsc.exe"
|
|
1244
|
+
}
|
|
1245
|
+
binary := filepath.Join(filepath.Dir(platformManifest), "lib", name)
|
|
1246
|
+
if stat, err := os.Stat(binary); err != nil || stat.IsDir() {
|
|
1247
|
+
return ""
|
|
1248
|
+
}
|
|
1249
|
+
return binary
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// stripResolveTtsxLauncher returns the launcher stripTtsxCommandContext spawns.
|
|
1253
|
+
//
|
|
1254
|
+
// An explicit TTSC_TTSX_BINARY wins. Otherwise the launcher is derived from the
|
|
1255
|
+
// `ttsc` installation one of the anchors can see, because a bare command name
|
|
1256
|
+
// only works when a bin link happens to be on PATH — which it is for a global
|
|
1257
|
+
// install and is not for the ordinary project-local one. The bare `"ttsx"` name
|
|
1258
|
+
// remains the unchanged last resort for an installation no anchor reaches.
|
|
1259
|
+
func stripResolveTtsxLauncher(anchors []string) string {
|
|
1260
|
+
if explicit := strings.TrimSpace(os.Getenv("TTSC_TTSX_BINARY")); explicit != "" {
|
|
1261
|
+
return explicit
|
|
1262
|
+
}
|
|
1263
|
+
for _, anchor := range anchors {
|
|
1264
|
+
if launcher := stripTtsxLauncherFrom(anchor); launcher != "" {
|
|
1265
|
+
return launcher
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return "ttsx"
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// stripTtsxLauncherFrom returns `lib/launcher/ttsx.js` of the `ttsc` install
|
|
1272
|
+
// `anchor` can see, or "" when this anchor reaches no such install. Only the
|
|
1273
|
+
// manifest is an exported subpath, so the launcher is derived from where the
|
|
1274
|
+
// manifest resolved rather than requested as a subpath of its own.
|
|
1275
|
+
func stripTtsxLauncherFrom(anchor string) string {
|
|
1276
|
+
manifest := stripNodePackageManifestFrom(anchor, "ttsc")
|
|
1277
|
+
if manifest == "" {
|
|
1278
|
+
return ""
|
|
1279
|
+
}
|
|
1280
|
+
launcher := filepath.Join(filepath.Dir(manifest), "lib", "launcher", "ttsx.js")
|
|
1281
|
+
if stat, err := os.Stat(launcher); err != nil || stat.IsDir() {
|
|
1282
|
+
return ""
|
|
1283
|
+
}
|
|
1284
|
+
return launcher
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// stripNodePackageManifestFrom resolves `<pkg>/package.json` the way Node's
|
|
1288
|
+
// require.resolve does from the FILE `anchor`: walk upward from the anchor's
|
|
1289
|
+
// directory and return the first `<dir>/node_modules/<pkg>/package.json` that
|
|
1290
|
+
// exists. The anchor is treated as a file path, so its own directory is the
|
|
1291
|
+
// first candidate's parent, and it need not exist — Node derives the search
|
|
1292
|
+
// paths from the string alone.
|
|
1293
|
+
//
|
|
1294
|
+
// A directory already named `node_modules` contributes no candidate of its own,
|
|
1295
|
+
// matching Module._nodeModulePaths, so nothing ever resolves through
|
|
1296
|
+
// `node_modules/node_modules`.
|
|
1297
|
+
//
|
|
1298
|
+
// A relative anchor is resolved against the process directory before the walk,
|
|
1299
|
+
// again matching Node. Walking a relative path instead would terminate at "."
|
|
1300
|
+
// after one step and silently answer nothing for a config named relatively.
|
|
1301
|
+
func stripNodePackageManifestFrom(anchor, pkg string) string {
|
|
1302
|
+
if strings.TrimSpace(anchor) == "" || pkg == "" {
|
|
1303
|
+
return ""
|
|
1304
|
+
}
|
|
1305
|
+
if absolute, err := filepath.Abs(anchor); err == nil {
|
|
1306
|
+
anchor = absolute
|
|
1307
|
+
}
|
|
1308
|
+
dir := filepath.Dir(filepath.Clean(anchor))
|
|
1309
|
+
for {
|
|
1310
|
+
if filepath.Base(dir) != "node_modules" {
|
|
1311
|
+
candidate := filepath.Join(dir, "node_modules", filepath.FromSlash(pkg), "package.json")
|
|
1312
|
+
if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
|
|
1313
|
+
return candidate
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
parent := filepath.Dir(dir)
|
|
1317
|
+
if parent == dir {
|
|
1318
|
+
return ""
|
|
1319
|
+
}
|
|
1320
|
+
dir = parent
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// stripNodePlatformPair is stripNodePlatformPairFor applied to this build's own
|
|
1325
|
+
// target.
|
|
1326
|
+
func stripNodePlatformPair() (string, string) {
|
|
1327
|
+
return stripNodePlatformPairFor(runtime.GOOS, runtime.GOARCH)
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// stripNodePlatformPairFor maps a Go build target onto the `process.platform`
|
|
1331
|
+
// and `process.arch` pair npm spells a platform package with, so the package
|
|
1332
|
+
// name this plugin resolves is the same one the JS launcher resolves.
|
|
1333
|
+
//
|
|
1334
|
+
// Only the members whose two vocabularies disagree are mapped. Every other
|
|
1335
|
+
// value is identical on both sides and passes through, which keeps a target
|
|
1336
|
+
// neither side publishes yet resolvable rather than silently wrong, and keeps
|
|
1337
|
+
// this from becoming a list that has to grow with every new port.
|
|
1338
|
+
func stripNodePlatformPairFor(goos, goarch string) (string, string) {
|
|
1339
|
+
platform := goos
|
|
1340
|
+
switch platform {
|
|
1341
|
+
case "windows":
|
|
1342
|
+
platform = "win32"
|
|
1343
|
+
case "solaris":
|
|
1344
|
+
platform = "sunos"
|
|
1345
|
+
}
|
|
1346
|
+
arch := goarch
|
|
1347
|
+
switch arch {
|
|
1348
|
+
case "amd64":
|
|
1349
|
+
arch = "x64"
|
|
1350
|
+
case "386":
|
|
1351
|
+
arch = "ia32"
|
|
1352
|
+
case "ppc64le":
|
|
1353
|
+
arch = "ppc64"
|
|
1354
|
+
}
|
|
1355
|
+
return platform, arch
|
|
1356
|
+
}
|
|
1357
|
+
|
|
539
1358
|
// stripTtsxCommandContext returns an exec.Cmd that runs ttsx with the given
|
|
540
1359
|
// arguments, routing through node when the resolved binary is a script file.
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
1360
|
+
//
|
|
1361
|
+
// `anchors` are the file paths the launcher is resolved from; see
|
|
1362
|
+
// stripResolveTtsxLauncher.
|
|
1363
|
+
func stripTtsxCommandContext(ctx context.Context, anchors []string, args ...string) *exec.Cmd {
|
|
1364
|
+
ttsx := stripResolveTtsxLauncher(anchors)
|
|
546
1365
|
if stripShouldRunThroughNode(ttsx) {
|
|
547
1366
|
node := os.Getenv("TTSC_NODE_BINARY")
|
|
548
1367
|
if node == "" {
|
|
@@ -673,3 +1492,5 @@ func loaderFailureReason(output []byte) string {
|
|
|
673
1492
|
}
|
|
674
1493
|
return strings.TrimSpace(envelope.Message)
|
|
675
1494
|
}
|
|
1495
|
+
|
|
1496
|
+
// ttsc:config-loader-shared end
|
package/driver/strip.go
CHANGED
|
@@ -19,7 +19,7 @@ type plugin struct{}
|
|
|
19
19
|
// ApplyProgram strips configured call expressions and debugger statements from
|
|
20
20
|
// every source file in the program.
|
|
21
21
|
func (plugin) ApplyProgram(prog *driver.Program, ctx driver.PluginContext) error {
|
|
22
|
-
config, err :=
|
|
22
|
+
config, err := loadStripConfigMapWithReporters(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig, ctx.ReportHostInput, ctx.ReportHostInputHash, ctx.ReportHostInputRealpath)
|
|
23
23
|
if err != nil {
|
|
24
24
|
return err
|
|
25
25
|
}
|
package/package.json
CHANGED
package/src/index.cjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const crypto = require("node:crypto");
|
|
4
6
|
const path = require("node:path");
|
|
5
7
|
|
|
6
8
|
// Keys from the tsconfig plugin entry that @ttsc/strip accepts. All other keys
|
|
@@ -28,7 +30,11 @@ module.exports = function createTtscStrip(context) {
|
|
|
28
30
|
);
|
|
29
31
|
}
|
|
30
32
|
}
|
|
33
|
+
const configInputs = stripConfigInputs(context);
|
|
31
34
|
return {
|
|
35
|
+
hostInputHashes: configInputs.hashes,
|
|
36
|
+
hostInputRealpaths: configInputs.realpaths,
|
|
37
|
+
hostInputs: configInputs.inputs,
|
|
32
38
|
name: "@ttsc/strip",
|
|
33
39
|
// `context.dirname` is this descriptor's own directory in every load mode —
|
|
34
40
|
// the ESM-safe replacement for `__dirname`.
|
|
@@ -36,3 +42,82 @@ module.exports = function createTtscStrip(context) {
|
|
|
36
42
|
stage: "transform",
|
|
37
43
|
};
|
|
38
44
|
};
|
|
45
|
+
|
|
46
|
+
const STRIP_CONFIG_FILENAMES = [
|
|
47
|
+
"strip.config.ts",
|
|
48
|
+
"strip.config.mts",
|
|
49
|
+
"strip.config.cts",
|
|
50
|
+
"strip.config.js",
|
|
51
|
+
"strip.config.mjs",
|
|
52
|
+
"strip.config.cjs",
|
|
53
|
+
"strip.config.json",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function stripConfigInputs(context) {
|
|
57
|
+
const configFile = context.plugin?.configFile;
|
|
58
|
+
const base = path.resolve(
|
|
59
|
+
context.pluginConfigDir ?? path.dirname(context.tsconfig),
|
|
60
|
+
);
|
|
61
|
+
if (typeof configFile === "string" && configFile.trim() !== "") {
|
|
62
|
+
const file = path.isAbsolute(configFile)
|
|
63
|
+
? path.resolve(configFile)
|
|
64
|
+
: path.resolve(base, configFile);
|
|
65
|
+
return {
|
|
66
|
+
hashes: { [file]: hostInputHash(file) },
|
|
67
|
+
inputs: [file],
|
|
68
|
+
realpaths: { [file]: hostInputRealpath(file) },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const inputs = [];
|
|
72
|
+
const hashes = {};
|
|
73
|
+
const realpaths = {};
|
|
74
|
+
for (let directory = base; ; directory = path.dirname(directory)) {
|
|
75
|
+
const candidates = STRIP_CONFIG_FILENAMES.map((name) =>
|
|
76
|
+
path.join(directory, name),
|
|
77
|
+
);
|
|
78
|
+
inputs.push(...candidates);
|
|
79
|
+
for (const candidate of candidates) {
|
|
80
|
+
hashes[candidate] = hostInputHash(candidate);
|
|
81
|
+
realpaths[candidate] = hostInputRealpath(candidate);
|
|
82
|
+
}
|
|
83
|
+
if (candidates.some(configCandidateExists)) break;
|
|
84
|
+
const parent = path.dirname(directory);
|
|
85
|
+
if (parent === directory) break;
|
|
86
|
+
}
|
|
87
|
+
return { hashes, inputs, realpaths };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hostInputRealpath(file) {
|
|
91
|
+
try {
|
|
92
|
+
return fs.realpathSync.native(file);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Hash the exact candidate state observed before discovery selects a file. */
|
|
99
|
+
function hostInputHash(file) {
|
|
100
|
+
try {
|
|
101
|
+
if (fs.statSync(file).isDirectory()) {
|
|
102
|
+
return crypto
|
|
103
|
+
.createHash("sha256")
|
|
104
|
+
.update("ttsc:host-input:directory\0")
|
|
105
|
+
.digest("hex");
|
|
106
|
+
}
|
|
107
|
+
return crypto
|
|
108
|
+
.createHash("sha256")
|
|
109
|
+
.update(fs.readFileSync(file))
|
|
110
|
+
.digest("hex");
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Match the native discovery rule: a directory is never a config file. */
|
|
117
|
+
function configCandidateExists(file) {
|
|
118
|
+
try {
|
|
119
|
+
return !fs.statSync(file).isDirectory();
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -14,9 +14,16 @@ declare function createTtscStrip(context: {
|
|
|
14
14
|
* it.
|
|
15
15
|
*/
|
|
16
16
|
dirname: string;
|
|
17
|
+
/** Host-declared anchor for implicit strip config discovery. */
|
|
18
|
+
pluginConfigDir?: string;
|
|
17
19
|
/** Original tsconfig plugin entry, validated for unsupported keys. */
|
|
18
20
|
plugin?: Record<string, unknown>;
|
|
21
|
+
/** Absolute resolved tsconfig path. */
|
|
22
|
+
tsconfig: string;
|
|
19
23
|
}): {
|
|
24
|
+
hostInputHashes: Record<string, string | null>;
|
|
25
|
+
hostInputRealpaths: Record<string, string | null>;
|
|
26
|
+
hostInputs: string[];
|
|
20
27
|
name: string;
|
|
21
28
|
source: string;
|
|
22
29
|
stage: "transform";
|