@ttsc/lint 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/lib/index.d.ts +1 -1
- package/lib/index.js +161 -46
- package/lib/index.js.map +1 -1
- package/lib/internal/configEvaluatorFailure.d.ts +24 -20
- package/lib/internal/configEvaluatorFailure.js +41 -54
- package/lib/internal/configEvaluatorFailure.js.map +1 -1
- package/linthost/config.go +317 -78
- package/linthost/fix.go +7 -1
- package/linthost/format.go +3 -1
- package/linthost/host.go +187 -14
- package/linthost/lsp.go +5 -1
- package/package.json +2 -2
- package/rule/project.go +12 -1
- package/src/index.ts +163 -53
- package/src/internal/configEvaluatorFailure.ts +35 -64
package/linthost/config.go
CHANGED
|
@@ -16,20 +16,10 @@ import (
|
|
|
16
16
|
"sort"
|
|
17
17
|
"strings"
|
|
18
18
|
"sync"
|
|
19
|
-
"time"
|
|
20
19
|
|
|
21
20
|
"github.com/samchon/ttsc/packages/ttsc/driver/windowsjunction"
|
|
22
21
|
)
|
|
23
22
|
|
|
24
|
-
// configLoaderTimeout caps every `ttsx`/`node -e` subprocess that
|
|
25
|
-
// evaluates a user-supplied lint config. The JS factory imposes the
|
|
26
|
-
// same 60 s budget on its mirroring spawnSync; without the Go-side cap
|
|
27
|
-
// a runaway user config would hang `ttsc-lint` forever, while
|
|
28
|
-
// `ttsc`/`pnpm` upstream of it stays responsive. 60 s is generous
|
|
29
|
-
// enough for cold ttsx starts on CI runners and tight enough to keep
|
|
30
|
-
// user-visible feedback under a minute.
|
|
31
|
-
const configLoaderTimeout = 60 * time.Second
|
|
32
|
-
|
|
33
23
|
// Severity is the `error | warning | off` ladder.
|
|
34
24
|
type Severity int
|
|
35
25
|
|
|
@@ -1315,6 +1305,7 @@ const (
|
|
|
1315
1305
|
configDependencyWatch = "watch"
|
|
1316
1306
|
configDependencyFile = "file"
|
|
1317
1307
|
configDependencyDir = "directory"
|
|
1308
|
+
configDependencyEntry = "entry"
|
|
1318
1309
|
configDependencyOptionalFile = "optional-file"
|
|
1319
1310
|
)
|
|
1320
1311
|
|
|
@@ -1376,7 +1367,10 @@ func loadConfigFileEvaluationWithin(
|
|
|
1376
1367
|
// configCacheVersion namespaces the on-disk config cache. Bump it whenever
|
|
1377
1368
|
// the shape of a cached config object changes so that entries written by an
|
|
1378
1369
|
// older @ttsc/lint binary are treated as a miss rather than silently reused.
|
|
1379
|
-
|
|
1370
|
+
// v6 adds the `entry` dependency kind. A v5 cache records a resolution trace's
|
|
1371
|
+
// ancestors as directory digests, so reusing it would keep republishing the
|
|
1372
|
+
// filesystem root as a watch input for as long as the entry survives.
|
|
1373
|
+
const configCacheVersion = "v6"
|
|
1380
1374
|
|
|
1381
1375
|
// configEvalCache memoizes evaluated .ts/.js lint config objects for the
|
|
1382
1376
|
// lifetime of one process; the on-disk cache (configCacheDir) extends the
|
|
@@ -1615,6 +1609,36 @@ func configDependencyDigest(
|
|
|
1615
1609
|
}
|
|
1616
1610
|
return hex.EncodeToString(h.Sum(nil)), nil
|
|
1617
1611
|
}
|
|
1612
|
+
// The `entry` digest observes one path's own existence and link topology.
|
|
1613
|
+
// It must reproduce the loader script's encoding byte for byte, because the
|
|
1614
|
+
// script writes the fingerprint and this function is what later decides the
|
|
1615
|
+
// cached evaluation is still current.
|
|
1616
|
+
if dependency.Kind == configDependencyEntry {
|
|
1617
|
+
info, err := os.Lstat(dependency.Path)
|
|
1618
|
+
if err != nil {
|
|
1619
|
+
digest := sha256.Sum256([]byte("missing\x00"))
|
|
1620
|
+
return hex.EncodeToString(digest[:]), nil
|
|
1621
|
+
}
|
|
1622
|
+
if info.Mode()&os.ModeSymlink != 0 {
|
|
1623
|
+
target, err := os.Readlink(dependency.Path)
|
|
1624
|
+
if err != nil {
|
|
1625
|
+
target = "<unreadable>"
|
|
1626
|
+
}
|
|
1627
|
+
h := sha256.New()
|
|
1628
|
+
h.Write([]byte("symlink\x00"))
|
|
1629
|
+
h.Write([]byte(target))
|
|
1630
|
+
return hex.EncodeToString(h.Sum(nil)), nil
|
|
1631
|
+
}
|
|
1632
|
+
kind := "other"
|
|
1633
|
+
switch {
|
|
1634
|
+
case info.IsDir():
|
|
1635
|
+
kind = "directory"
|
|
1636
|
+
case info.Mode().IsRegular():
|
|
1637
|
+
kind = "file"
|
|
1638
|
+
}
|
|
1639
|
+
digest := sha256.Sum256([]byte(kind + "\x00"))
|
|
1640
|
+
return hex.EncodeToString(digest[:]), nil
|
|
1641
|
+
}
|
|
1618
1642
|
if dependency.Kind == configDependencyOptionalFile {
|
|
1619
1643
|
info, err := os.Stat(dependency.Path)
|
|
1620
1644
|
if err != nil || !info.Mode().IsRegular() {
|
|
@@ -1747,39 +1771,31 @@ func serializableConfigKeysLiteral() string {
|
|
|
1747
1771
|
|
|
1748
1772
|
// runConfigLoaderCommand runs a prepared config-loader subprocess (`cmd`),
|
|
1749
1773
|
// then turns its result into a parsed config object. It owns the shared tail
|
|
1750
|
-
// of both subprocess-backed loaders: discarding user stdout,
|
|
1751
|
-
//
|
|
1752
|
-
//
|
|
1753
|
-
//
|
|
1754
|
-
// for error messages; `label` is the human-readable subject (e.g. "config
|
|
1774
|
+
// of both subprocess-backed loaders: discarding user stdout, streaming user
|
|
1775
|
+
// stderr through, reading the private result file, JSON-parsing its envelope,
|
|
1776
|
+
// and rejecting a non-object result. `location` is the config file
|
|
1777
|
+
// path for error messages; `label` is the human-readable subject (e.g. "config
|
|
1755
1778
|
// file" or "TypeScript config file") spliced into the load/parse error
|
|
1756
1779
|
// prefixes so each loader keeps its own wording.
|
|
1757
1780
|
func runConfigLoaderCommand(
|
|
1758
|
-
ctx context.Context,
|
|
1759
1781
|
cmd *exec.Cmd,
|
|
1760
1782
|
location string,
|
|
1761
1783
|
label string,
|
|
1762
1784
|
outputPath string,
|
|
1763
1785
|
) (evaluatedConfigFile, error) {
|
|
1764
|
-
|
|
1786
|
+
// The child's stderr is human output and goes straight to this process's
|
|
1787
|
+
// stderr as it is written. Collecting it only to replay it afterwards is what
|
|
1788
|
+
// made a long evaluation print nothing at all, and what would make a loud one
|
|
1789
|
+
// grow this process's memory without bound.
|
|
1765
1790
|
cmd.Stdout = io.Discard
|
|
1766
|
-
cmd.Stderr =
|
|
1791
|
+
cmd.Stderr = os.Stderr
|
|
1767
1792
|
err := cmd.Run()
|
|
1768
|
-
// A loader diagnostic is only useful when the load succeeds, because a
|
|
1769
|
-
// failure already carries the same text in its message. Forward it so an
|
|
1770
|
-
// assertion about what the loader recorded can name what it resolved.
|
|
1771
|
-
if err == nil && os.Getenv("TTSC_LINT_DEBUG_CONFIG_GRAPH") != "" {
|
|
1772
|
-
if text := strings.TrimSpace(stderr.String()); text != "" {
|
|
1773
|
-
fmt.Fprintln(os.Stderr, text)
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
1793
|
if err != nil {
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: load %s %s: %s", label, location, stderrText)
|
|
1794
|
+
// The loader's stack already reached the user's stderr as it was written.
|
|
1795
|
+
// What it could not put there is a reason a caller can act on, so that
|
|
1796
|
+
// arrives through the result file instead.
|
|
1797
|
+
if reason := loaderFailureReason(outputPath); reason != "" {
|
|
1798
|
+
return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: load %s %s: %s", label, location, reason)
|
|
1783
1799
|
}
|
|
1784
1800
|
return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: load %s %s: %w", label, location, err)
|
|
1785
1801
|
}
|
|
@@ -1836,6 +1852,7 @@ func normalizeConfigDependencyFingerprints(
|
|
|
1836
1852
|
strings.ToLower(dependency.Digest) != dependency.Digest ||
|
|
1837
1853
|
(dependency.Kind != configDependencyFile &&
|
|
1838
1854
|
dependency.Kind != configDependencyDir &&
|
|
1855
|
+
dependency.Kind != configDependencyEntry &&
|
|
1839
1856
|
dependency.Kind != configDependencyOptionalFile) ||
|
|
1840
1857
|
(dependency.Scope != configDependencyCache &&
|
|
1841
1858
|
dependency.Scope != configDependencyWatch) {
|
|
@@ -1872,8 +1889,7 @@ func normalizeConfigDependencyFingerprints(
|
|
|
1872
1889
|
// loadScriptConfigFile evaluates a .js/.cjs/.mjs config file by running a
|
|
1873
1890
|
// Node subprocess that dynamic-imports the file, resolves the exported config
|
|
1874
1891
|
// through the same 8-hop default/config normalization used by the TS loader,
|
|
1875
|
-
// and serializes the result into a private result file.
|
|
1876
|
-
// configLoaderTimeout deadline to prevent user code from hanging indefinitely.
|
|
1892
|
+
// and serializes the result into a private result file.
|
|
1877
1893
|
func loadScriptConfigFile(location string) (any, error) {
|
|
1878
1894
|
evaluated, err := loadScriptConfigEvaluation(location)
|
|
1879
1895
|
return evaluated.value, err
|
|
@@ -1898,7 +1914,7 @@ func loadScriptConfigEvaluationWithin(
|
|
|
1898
1914
|
if node == "" {
|
|
1899
1915
|
node = "node"
|
|
1900
1916
|
}
|
|
1901
|
-
ctx, cancel := context.
|
|
1917
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
1902
1918
|
defer cancel()
|
|
1903
1919
|
cmd := exec.CommandContext(
|
|
1904
1920
|
ctx,
|
|
@@ -1909,7 +1925,7 @@ func loadScriptConfigEvaluationWithin(
|
|
|
1909
1925
|
outputPath,
|
|
1910
1926
|
resolutionRoot,
|
|
1911
1927
|
)
|
|
1912
|
-
return runConfigLoaderCommand(
|
|
1928
|
+
return runConfigLoaderCommand(cmd, location, "config file", outputPath)
|
|
1913
1929
|
}
|
|
1914
1930
|
|
|
1915
1931
|
// scriptConfigLoaderSource returns the CommonJS source of the loader script
|
|
@@ -2037,6 +2053,12 @@ const hooks = registerHooks({
|
|
|
2037
2053
|
}
|
|
2038
2054
|
})().catch((error) => {
|
|
2039
2055
|
process.stderr.write(error && error.stack ? error.stack : String(error));
|
|
2056
|
+
// The stack above is for the reader. This is for the caller: the parent reads
|
|
2057
|
+
// the result file either way, so a failure reason travels as data rather than
|
|
2058
|
+
// as text scraped back out of a captured stream.
|
|
2059
|
+
try {
|
|
2060
|
+
fs.writeFileSync(outputPath, JSON.stringify({ __ttscLoaderError: error && error.message ? String(error.message) : String(error) }), "utf8");
|
|
2061
|
+
} catch {}
|
|
2040
2062
|
process.exit(1);
|
|
2041
2063
|
});
|
|
2042
2064
|
|
|
@@ -2139,6 +2161,55 @@ function recordDirectoryDependency(location, owners) {
|
|
|
2139
2161
|
}
|
|
2140
2162
|
}
|
|
2141
2163
|
|
|
2164
|
+
// Observe one path's own existence and link topology instead of enumerating the
|
|
2165
|
+
// directory that contains it. A resolution trace passes through ancestors it
|
|
2166
|
+
// does not own -- /var on macOS is a symlink whose parent is the filesystem
|
|
2167
|
+
// root -- and digesting that parent both reaches outside the project boundary
|
|
2168
|
+
// and reads the whole directory to learn one entry's state.
|
|
2169
|
+
// A path candidate is observed through the directory that would own a
|
|
2170
|
+
// competing resolution, so a sibling winning extension resolution still
|
|
2171
|
+
// invalidates. That reasoning is what the parent digest is for and it stays.
|
|
2172
|
+
//
|
|
2173
|
+
// It does not reach the filesystem root. The root owns no candidate this trace
|
|
2174
|
+
// could pick, and a resolution path routinely passes through an ancestor
|
|
2175
|
+
// directly beneath it -- /var on macOS is a symlink whose parent is the root --
|
|
2176
|
+
// so digesting the parent there enumerates the entire filesystem root on every
|
|
2177
|
+
// config load, outside the project boundary. Record that one ancestor instead.
|
|
2178
|
+
function recordAncestorDependency(parent, entry, root, owners) {
|
|
2179
|
+
if (parent === root) recordEntryDependency(entry, owners);
|
|
2180
|
+
else recordDirectoryDependency(parent, owners);
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
function recordEntryDependency(location, owners) {
|
|
2184
|
+
recordDependency("entry", location, entryDigest(location), owners);
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
function entryDigest(location) {
|
|
2188
|
+
let entry;
|
|
2189
|
+
try {
|
|
2190
|
+
entry = fs.lstatSync(location);
|
|
2191
|
+
} catch {
|
|
2192
|
+
return createHash("sha256").update("missing\0").digest("hex");
|
|
2193
|
+
}
|
|
2194
|
+
if (entry.isSymbolicLink()) {
|
|
2195
|
+
let target;
|
|
2196
|
+
try {
|
|
2197
|
+
target = fs.readlinkSync(location, { encoding: "buffer" });
|
|
2198
|
+
} catch {
|
|
2199
|
+
target = Buffer.from("<unreadable>");
|
|
2200
|
+
}
|
|
2201
|
+
return createHash("sha256")
|
|
2202
|
+
.update(Buffer.concat([Buffer.from("symlink\0"), target]))
|
|
2203
|
+
.digest("hex");
|
|
2204
|
+
}
|
|
2205
|
+
const kind = entry.isDirectory()
|
|
2206
|
+
? "directory"
|
|
2207
|
+
: entry.isFile()
|
|
2208
|
+
? "file"
|
|
2209
|
+
: "other";
|
|
2210
|
+
return createHash("sha256").update(kind + "\0").digest("hex");
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2142
2213
|
function directoryDigest(location) {
|
|
2143
2214
|
const entries = [];
|
|
2144
2215
|
if (process.platform === "win32") {
|
|
@@ -2691,11 +2762,11 @@ function recordPackagePathCandidate(
|
|
|
2691
2762
|
try {
|
|
2692
2763
|
entry = fs.lstatSync(next);
|
|
2693
2764
|
} catch {
|
|
2694
|
-
|
|
2765
|
+
recordAncestorDependency(current, next, parsed.root, owners);
|
|
2695
2766
|
return;
|
|
2696
2767
|
}
|
|
2697
2768
|
if (entry.isSymbolicLink()) {
|
|
2698
|
-
|
|
2769
|
+
recordAncestorDependency(current, next, parsed.root, owners);
|
|
2699
2770
|
try {
|
|
2700
2771
|
const target = fs.readlinkSync(next);
|
|
2701
2772
|
const remainder = components.slice(index + 1);
|
|
@@ -2717,16 +2788,20 @@ function recordPackagePathCandidate(
|
|
|
2717
2788
|
}
|
|
2718
2789
|
}
|
|
2719
2790
|
if (index === components.length - 1) {
|
|
2720
|
-
|
|
2791
|
+
if (isDirectory) recordDirectoryDependency(next, owners);
|
|
2792
|
+
else recordAncestorDependency(current, next, parsed.root, owners);
|
|
2721
2793
|
return;
|
|
2722
2794
|
}
|
|
2723
2795
|
if (!isDirectory) {
|
|
2724
|
-
|
|
2796
|
+
recordAncestorDependency(current, next, parsed.root, owners);
|
|
2725
2797
|
return;
|
|
2726
2798
|
}
|
|
2727
2799
|
current = next;
|
|
2728
2800
|
}
|
|
2729
|
-
|
|
2801
|
+
// Reached only when the candidate resolved to the filesystem root itself,
|
|
2802
|
+
// which names no package. Record its existence, not its listing.
|
|
2803
|
+
if (current === parsed.root) recordEntryDependency(current, owners);
|
|
2804
|
+
else recordDirectoryDependency(current, owners);
|
|
2730
2805
|
}
|
|
2731
2806
|
|
|
2732
2807
|
function modulePackageName(specifier) {
|
|
@@ -2877,7 +2952,7 @@ function toSerializableConfig(value) {
|
|
|
2877
2952
|
|
|
2878
2953
|
// loadTypeScriptConfigFile evaluates a .ts/.cts/.mts config file by writing
|
|
2879
2954
|
// an ephemeral loader script and tsconfig into a temp directory, symlinking the
|
|
2880
|
-
// nearest node_modules, then running `ttsx
|
|
2955
|
+
// nearest node_modules, then running `ttsx`.
|
|
2881
2956
|
// The loader script imports the config file, resolves it through the same
|
|
2882
2957
|
// normalization chain used by loadScriptConfigFile, and writes a private JSON
|
|
2883
2958
|
// result file so user stdout cannot corrupt the protocol.
|
|
@@ -2958,11 +3033,11 @@ func loadTypeScriptConfigEvaluationWithin(
|
|
|
2958
3033
|
}
|
|
2959
3034
|
args = append(args, loader)
|
|
2960
3035
|
|
|
2961
|
-
ctx, cancel := context.
|
|
3036
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
2962
3037
|
defer cancel()
|
|
2963
3038
|
cmd := ttsxCommandContext(ctx, args...)
|
|
2964
3039
|
cmd.Env = nodeConfigLoaderEnv(location)
|
|
2965
|
-
return runConfigLoaderCommand(
|
|
3040
|
+
return runConfigLoaderCommand(cmd, location, "TypeScript config file", outputPath)
|
|
2966
3041
|
}
|
|
2967
3042
|
|
|
2968
3043
|
// isConfigObject reports whether `value` is a top-level config object. A lint
|
|
@@ -3045,7 +3120,7 @@ const resolutionRoot = path.resolve(%s);
|
|
|
3045
3120
|
const CONFIG_KEYS = new Set<string>([%s]);
|
|
3046
3121
|
const dependencies = new Map<string, {
|
|
3047
3122
|
digest: string;
|
|
3048
|
-
kind: "directory" | "file" | "optional-file";
|
|
3123
|
+
kind: "directory" | "entry" | "file" | "optional-file";
|
|
3049
3124
|
path: string;
|
|
3050
3125
|
owners: Set<string>;
|
|
3051
3126
|
}>();
|
|
@@ -3151,22 +3226,36 @@ const hooks = registerHooks({
|
|
|
3151
3226
|
},
|
|
3152
3227
|
});
|
|
3153
3228
|
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
value
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
}
|
|
3229
|
+
// Wrapped rather than written as a top-level await: the loader tsconfig's
|
|
3230
|
+
// "module" follows the config's own package, and TS1378 rejects top-level await
|
|
3231
|
+
// under a CommonJS module option however this .mts file emits. The body's own
|
|
3232
|
+
// catch is the only failure path — it ends the process — so there is nothing
|
|
3233
|
+
// left for a trailing handler to settle.
|
|
3234
|
+
(async () => {
|
|
3235
|
+
try {
|
|
3236
|
+
const importedConfig = await import(configUrl);
|
|
3237
|
+
const value = await resolveConfig(importedConfig, true);
|
|
3238
|
+
if (!isObject(value) || Array.isArray(value)) {
|
|
3239
|
+
throw new Error("config file must export an ITtscLintConfig object");
|
|
3240
|
+
}
|
|
3241
|
+
fs.writeFileSync(outputPath, JSON.stringify({
|
|
3242
|
+
dependencies: finalizeDependencies(),
|
|
3243
|
+
value: toSerializableConfig(value),
|
|
3244
|
+
}), "utf8");
|
|
3245
|
+
} catch (error) {
|
|
3246
|
+
process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
|
|
3247
|
+
// The stack above is for the reader. This is for the caller: the parent
|
|
3248
|
+
// reads the result file either way, so a failure reason travels as data
|
|
3249
|
+
// rather than as text scraped back out of a captured stream. A write that
|
|
3250
|
+
// itself fails leaves the process status to speak.
|
|
3251
|
+
try {
|
|
3252
|
+
fs.writeFileSync(outputPath, JSON.stringify({ __ttscLoaderError: error instanceof Error ? error.message : String(error) }), "utf8");
|
|
3253
|
+
} catch {}
|
|
3254
|
+
process.exit(1);
|
|
3255
|
+
} finally {
|
|
3256
|
+
hooks.deregister();
|
|
3257
|
+
}
|
|
3258
|
+
})();
|
|
3170
3259
|
|
|
3171
3260
|
async function resolveConfig(value: unknown, allowNamedConfig: boolean): Promise<unknown> {
|
|
3172
3261
|
let current = value;
|
|
@@ -3207,7 +3296,7 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
3207
3296
|
}
|
|
3208
3297
|
|
|
3209
3298
|
function recordDependency(
|
|
3210
|
-
kind: "directory" | "file" | "optional-file",
|
|
3299
|
+
kind: "directory" | "entry" | "file" | "optional-file",
|
|
3211
3300
|
location: string,
|
|
3212
3301
|
digest: string,
|
|
3213
3302
|
owners: readonly string[],
|
|
@@ -3271,6 +3360,63 @@ function recordDirectoryDependency(
|
|
|
3271
3360
|
}
|
|
3272
3361
|
}
|
|
3273
3362
|
|
|
3363
|
+
// Observe one path's own existence and link topology instead of enumerating the
|
|
3364
|
+
// directory that contains it. A resolution trace passes through ancestors it
|
|
3365
|
+
// does not own -- /var on macOS is a symlink whose parent is the filesystem
|
|
3366
|
+
// root -- and digesting that parent both reaches outside the project boundary
|
|
3367
|
+
// and reads the whole directory to learn one entry's state.
|
|
3368
|
+
// A path candidate is observed through the directory that would own a
|
|
3369
|
+
// competing resolution, so a sibling winning extension resolution still
|
|
3370
|
+
// invalidates. That reasoning is what the parent digest is for and it stays.
|
|
3371
|
+
//
|
|
3372
|
+
// It does not reach the filesystem root. The root owns no candidate this trace
|
|
3373
|
+
// could pick, and a resolution path routinely passes through an ancestor
|
|
3374
|
+
// directly beneath it -- /var on macOS is a symlink whose parent is the root --
|
|
3375
|
+
// so digesting the parent there enumerates the entire filesystem root on every
|
|
3376
|
+
// config load, outside the project boundary. Record that one ancestor instead.
|
|
3377
|
+
function recordAncestorDependency(
|
|
3378
|
+
parent: string,
|
|
3379
|
+
entry: string,
|
|
3380
|
+
root: string,
|
|
3381
|
+
owners: readonly string[],
|
|
3382
|
+
): void {
|
|
3383
|
+
if (parent === root) recordEntryDependency(entry, owners);
|
|
3384
|
+
else recordDirectoryDependency(parent, owners);
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
function recordEntryDependency(
|
|
3388
|
+
location: string,
|
|
3389
|
+
owners: readonly string[],
|
|
3390
|
+
): void {
|
|
3391
|
+
recordDependency("entry", location, entryDigest(location), owners);
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
function entryDigest(location: string): string {
|
|
3395
|
+
let entry: ReturnType<typeof fs.lstatSync>;
|
|
3396
|
+
try {
|
|
3397
|
+
entry = fs.lstatSync(location);
|
|
3398
|
+
} catch {
|
|
3399
|
+
return createHash("sha256").update("missing\0").digest("hex");
|
|
3400
|
+
}
|
|
3401
|
+
if (entry.isSymbolicLink()) {
|
|
3402
|
+
let target: Buffer;
|
|
3403
|
+
try {
|
|
3404
|
+
target = fs.readlinkSync(location, { encoding: "buffer" });
|
|
3405
|
+
} catch {
|
|
3406
|
+
target = Buffer.from("<unreadable>");
|
|
3407
|
+
}
|
|
3408
|
+
return createHash("sha256")
|
|
3409
|
+
.update(Buffer.concat([Buffer.from("symlink\0"), target]))
|
|
3410
|
+
.digest("hex");
|
|
3411
|
+
}
|
|
3412
|
+
const kind = entry.isDirectory()
|
|
3413
|
+
? "directory"
|
|
3414
|
+
: entry.isFile()
|
|
3415
|
+
? "file"
|
|
3416
|
+
: "other";
|
|
3417
|
+
return createHash("sha256").update(kind + "\0").digest("hex");
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3274
3420
|
function directoryDigest(location: string): string {
|
|
3275
3421
|
const entries: Buffer[] = [];
|
|
3276
3422
|
if (process.platform === "win32") {
|
|
@@ -3851,11 +3997,11 @@ function recordPackagePathCandidate(
|
|
|
3851
3997
|
try {
|
|
3852
3998
|
entry = fs.lstatSync(next);
|
|
3853
3999
|
} catch {
|
|
3854
|
-
|
|
4000
|
+
recordAncestorDependency(current, next, parsed.root, owners);
|
|
3855
4001
|
return;
|
|
3856
4002
|
}
|
|
3857
4003
|
if (entry.isSymbolicLink()) {
|
|
3858
|
-
|
|
4004
|
+
recordAncestorDependency(current, next, parsed.root, owners);
|
|
3859
4005
|
try {
|
|
3860
4006
|
const target = fs.readlinkSync(next);
|
|
3861
4007
|
const remainder = components.slice(index + 1);
|
|
@@ -3877,16 +4023,20 @@ function recordPackagePathCandidate(
|
|
|
3877
4023
|
}
|
|
3878
4024
|
}
|
|
3879
4025
|
if (index === components.length - 1) {
|
|
3880
|
-
|
|
4026
|
+
if (isDirectory) recordDirectoryDependency(next, owners);
|
|
4027
|
+
else recordAncestorDependency(current, next, parsed.root, owners);
|
|
3881
4028
|
return;
|
|
3882
4029
|
}
|
|
3883
4030
|
if (!isDirectory) {
|
|
3884
|
-
|
|
4031
|
+
recordAncestorDependency(current, next, parsed.root, owners);
|
|
3885
4032
|
return;
|
|
3886
4033
|
}
|
|
3887
4034
|
current = next;
|
|
3888
4035
|
}
|
|
3889
|
-
|
|
4036
|
+
// Reached only when the candidate resolved to the filesystem root itself,
|
|
4037
|
+
// which names no package. Record its existence, not its listing.
|
|
4038
|
+
if (current === parsed.root) recordEntryDependency(current, owners);
|
|
4039
|
+
else recordDirectoryDependency(current, owners);
|
|
3890
4040
|
}
|
|
3891
4041
|
|
|
3892
4042
|
function modulePackageName(specifier: string): string | undefined {
|
|
@@ -3964,7 +4114,7 @@ function realPath(location: string): string {
|
|
|
3964
4114
|
|
|
3965
4115
|
function finalizeDependencies(): Array<{
|
|
3966
4116
|
digest: string;
|
|
3967
|
-
kind: "directory" | "file" | "optional-file";
|
|
4117
|
+
kind: "directory" | "entry" | "file" | "optional-file";
|
|
3968
4118
|
path: string;
|
|
3969
4119
|
scope: "cache" | "watch";
|
|
3970
4120
|
}> {
|
|
@@ -4089,10 +4239,16 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
|
|
|
4089
4239
|
// false` is the right baseline.
|
|
4090
4240
|
content := map[string]any{
|
|
4091
4241
|
"compilerOptions": map[string]any{
|
|
4092
|
-
"allowImportingTsExtensions":
|
|
4093
|
-
"allowJs":
|
|
4094
|
-
"checkJs":
|
|
4095
|
-
|
|
4242
|
+
"allowImportingTsExtensions": true,
|
|
4243
|
+
"allowJs": true,
|
|
4244
|
+
"checkJs": false,
|
|
4245
|
+
// The config is a Node module, so Node's rule decides its format: the
|
|
4246
|
+
// nearest package.json "type" above it. Hardcoding one answer ran every
|
|
4247
|
+
// ambiguous `.ts` config as ESM and broke __dirname in an ordinary
|
|
4248
|
+
// CommonJS package (#1068). moduleResolution stays "bundler", which tsgo
|
|
4249
|
+
// accepts for both kinds, so extensionless relative imports keep
|
|
4250
|
+
// resolving either way.
|
|
4251
|
+
"module": configModuleOption(location),
|
|
4096
4252
|
"moduleResolution": "bundler",
|
|
4097
4253
|
"noImplicitAny": false,
|
|
4098
4254
|
"outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
|
|
@@ -4101,6 +4257,12 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
|
|
|
4101
4257
|
"skipLibCheck": true,
|
|
4102
4258
|
"strict": false,
|
|
4103
4259
|
"target": "ES2022",
|
|
4260
|
+
// TypeScript 7 includes no ambient type package unless "types" asks for
|
|
4261
|
+
// it, and this Program extends nothing, so without the wildcard a config
|
|
4262
|
+
// could not name a single Node global (#1068). The loader directory links
|
|
4263
|
+
// the config's nearest node_modules, so the default typeRoots walk finds
|
|
4264
|
+
// exactly what the project installed.
|
|
4265
|
+
"types": []string{"*"},
|
|
4104
4266
|
},
|
|
4105
4267
|
"files": []string{
|
|
4106
4268
|
filepath.ToSlash(loader),
|
|
@@ -4114,6 +4276,56 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
|
|
|
4114
4276
|
return string(body)
|
|
4115
4277
|
}
|
|
4116
4278
|
|
|
4279
|
+
// configModuleOption returns the loader tsconfig's "module" for a config file:
|
|
4280
|
+
// the module kind Node itself would give that file.
|
|
4281
|
+
//
|
|
4282
|
+
// An explicit .cts/.cjs or .mts/.mjs extension already decides the emit format
|
|
4283
|
+
// on its own, so those keep the ES-module setting and let the extension win —
|
|
4284
|
+
// the same precedence tsgo applies. Everything ambiguous walks up for the
|
|
4285
|
+
// nearest package.json "type", exactly as Node does when it loads the file.
|
|
4286
|
+
func configModuleOption(location string) string {
|
|
4287
|
+
switch strings.ToLower(filepath.Ext(location)) {
|
|
4288
|
+
case ".ts", ".tsx", ".js":
|
|
4289
|
+
if nearestPackageType(location) == "commonjs" {
|
|
4290
|
+
return "CommonJS"
|
|
4291
|
+
}
|
|
4292
|
+
}
|
|
4293
|
+
return "ESNext"
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
// nearestPackageType mirrors Node's package-scope lookup for the nearest
|
|
4297
|
+
// package.json above location: the walk stops at the FIRST manifest it finds,
|
|
4298
|
+
// and a manifest declaring no "type" means CommonJS rather than a reason to
|
|
4299
|
+
// keep climbing. Reaching the filesystem root without any manifest also means
|
|
4300
|
+
// CommonJS. The location is made absolute first, so a relative config path
|
|
4301
|
+
// cannot end the walk at "." after a single step.
|
|
4302
|
+
func nearestPackageType(location string) string {
|
|
4303
|
+
absolute, err := filepath.Abs(location)
|
|
4304
|
+
if err != nil {
|
|
4305
|
+
absolute = location
|
|
4306
|
+
}
|
|
4307
|
+
dir := filepath.Dir(absolute)
|
|
4308
|
+
for {
|
|
4309
|
+
raw, err := os.ReadFile(filepath.Join(dir, "package.json"))
|
|
4310
|
+
if err == nil {
|
|
4311
|
+
var manifest struct {
|
|
4312
|
+
Type string `json:"type"`
|
|
4313
|
+
}
|
|
4314
|
+
// A manifest that does not parse still bounds the package scope; Node
|
|
4315
|
+
// refuses to look past it, and CommonJS is the format it defaults to.
|
|
4316
|
+
if json.Unmarshal(raw, &manifest) == nil && manifest.Type == "module" {
|
|
4317
|
+
return "module"
|
|
4318
|
+
}
|
|
4319
|
+
return "commonjs"
|
|
4320
|
+
}
|
|
4321
|
+
parent := filepath.Dir(dir)
|
|
4322
|
+
if parent == dir {
|
|
4323
|
+
return "commonjs"
|
|
4324
|
+
}
|
|
4325
|
+
dir = parent
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
4328
|
+
|
|
4117
4329
|
// loaderRootDir returns the widest rootDir that still contains the loader
|
|
4118
4330
|
// tsconfig's inputs: the volume root of the loader temp dir (`C:/` on
|
|
4119
4331
|
// Windows, `/` elsewhere). A literal "/" is not an ancestor of drive-letter
|
|
@@ -4195,15 +4407,15 @@ func resolveDirLink(dir string) string {
|
|
|
4195
4407
|
}
|
|
4196
4408
|
|
|
4197
4409
|
// ttsxCommand returns a ttsx exec.Cmd bound to a background context. Use
|
|
4198
|
-
// ttsxCommandContext when
|
|
4410
|
+
// ttsxCommandContext when the caller owns a cancellable context.
|
|
4199
4411
|
func ttsxCommand(args ...string) *exec.Cmd {
|
|
4200
4412
|
return ttsxCommandContext(context.Background(), args...)
|
|
4201
4413
|
}
|
|
4202
4414
|
|
|
4203
|
-
// ttsxCommandContext is the
|
|
4204
|
-
//
|
|
4205
|
-
//
|
|
4206
|
-
//
|
|
4415
|
+
// ttsxCommandContext is the cancellable variant, used by the config loaders so
|
|
4416
|
+
// their subprocess is torn down with the call that started it. It carries no
|
|
4417
|
+
// deadline: evaluating a user config is the user's own code running, and how
|
|
4418
|
+
// long that is allowed to take is not this binary's decision.
|
|
4207
4419
|
func ttsxCommandContext(ctx context.Context, args ...string) *exec.Cmd {
|
|
4208
4420
|
ttsx := os.Getenv("TTSC_TTSX_BINARY")
|
|
4209
4421
|
if ttsx == "" {
|
|
@@ -4572,3 +4784,30 @@ func (c RuleConfig) Severity(name string) Severity {
|
|
|
4572
4784
|
}
|
|
4573
4785
|
return SeverityOff
|
|
4574
4786
|
}
|
|
4787
|
+
|
|
4788
|
+
// loaderFailureReason reads the failure envelope a config loader writes to its
|
|
4789
|
+
// private result file when it stops on an error it can name.
|
|
4790
|
+
//
|
|
4791
|
+
// The loader's stack goes to this process's stderr as it runs, which is where a
|
|
4792
|
+
// reader wants it. But the reason — "config file must export an ITtscLintConfig
|
|
4793
|
+
// object" — is a fact about the user's config, and a caller deserves it in the
|
|
4794
|
+
// error rather than having to go find it in the log. Only a well-formed
|
|
4795
|
+
// envelope is honoured; anything else leaves the process status to speak.
|
|
4796
|
+
//
|
|
4797
|
+
// The key is `__ttscLoaderError` — the same spelling every other ttsc loader
|
|
4798
|
+
// writes, and namespaced so it cannot collide with a payload field. This file
|
|
4799
|
+
// spends "error" on rule severity, which is exactly the confusion a shared,
|
|
4800
|
+
// prefixed key avoids.
|
|
4801
|
+
func loaderFailureReason(outputPath string) string {
|
|
4802
|
+
raw, err := os.ReadFile(outputPath)
|
|
4803
|
+
if err != nil {
|
|
4804
|
+
return ""
|
|
4805
|
+
}
|
|
4806
|
+
var envelope struct {
|
|
4807
|
+
Error string `json:"__ttscLoaderError"`
|
|
4808
|
+
}
|
|
4809
|
+
if json.Unmarshal(raw, &envelope) != nil {
|
|
4810
|
+
return ""
|
|
4811
|
+
}
|
|
4812
|
+
return strings.TrimSpace(envelope.Error)
|
|
4813
|
+
}
|
package/linthost/fix.go
CHANGED
|
@@ -83,8 +83,14 @@ func runFix(opts *subcommandOpts) int {
|
|
|
83
83
|
// kinds of findings in one pass — no filtering needed here.
|
|
84
84
|
cascadeConverged := false
|
|
85
85
|
for pass := 0; pass < maxFixPasses; pass++ {
|
|
86
|
+
// Fix reads the whole lint scope because it prints diagnostics once the
|
|
87
|
+
// cascade settles, and writes only the project's own files: an imported
|
|
88
|
+
// source reaches the report below, never the disk.
|
|
86
89
|
findings := prog.runLintCycle(engine)
|
|
87
|
-
fixed, err := applyFindingFixes(
|
|
90
|
+
fixed, err := applyFindingFixes(
|
|
91
|
+
opts.cwd,
|
|
92
|
+
prog.projectWritableFindings(findings),
|
|
93
|
+
)
|
|
88
94
|
if err != nil {
|
|
89
95
|
fmt.Fprintln(os.Stderr, err)
|
|
90
96
|
return 3
|
package/linthost/format.go
CHANGED
|
@@ -68,7 +68,9 @@ func runFormat(opts *subcommandOpts) int {
|
|
|
68
68
|
totalFixes := 0
|
|
69
69
|
cascadeConverged := false
|
|
70
70
|
for pass := 0; pass < maxFormatPasses; pass++ {
|
|
71
|
-
|
|
71
|
+
// Format's cycle stays inside the project's own sources, so every finding
|
|
72
|
+
// it sees is one it may write.
|
|
73
|
+
findings := prog.runWriteScopedCycle(engine)
|
|
72
74
|
fixed, err := applyFindingFixes(opts.cwd, filterFormatFindings(findings))
|
|
73
75
|
if err != nil {
|
|
74
76
|
fmt.Fprintln(os.Stderr, err)
|