@ttsc/lint 0.23.0 → 0.24.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/linthost/config.go +163 -14
- 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/README.md
CHANGED
|
@@ -1169,9 +1169,10 @@ type noCycles struct{}
|
|
|
1169
1169
|
|
|
1170
1170
|
func (noCycles) Name() string { return "architecture/no-cycles" }
|
|
1171
1171
|
func (noCycles) Check(ctx *rule.ProjectContext) {
|
|
1172
|
-
// ctx.Sources is the
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1172
|
+
// ctx.Sources is the user-source set the host read: the tsconfig file list
|
|
1173
|
+
// plus every TypeScript source the Program reached through an import, and it
|
|
1174
|
+
// may be empty. ctx.Checker is the Program checker, and ctx.Identity keeps
|
|
1175
|
+
// logical and physical project paths separate.
|
|
1175
1176
|
if cycle := findCycle(ctx.Sources, ctx.Checker); cycle != "" {
|
|
1176
1177
|
ctx.Report(cycle)
|
|
1177
1178
|
}
|
package/linthost/config.go
CHANGED
|
@@ -1315,6 +1315,7 @@ const (
|
|
|
1315
1315
|
configDependencyWatch = "watch"
|
|
1316
1316
|
configDependencyFile = "file"
|
|
1317
1317
|
configDependencyDir = "directory"
|
|
1318
|
+
configDependencyEntry = "entry"
|
|
1318
1319
|
configDependencyOptionalFile = "optional-file"
|
|
1319
1320
|
)
|
|
1320
1321
|
|
|
@@ -1376,7 +1377,10 @@ func loadConfigFileEvaluationWithin(
|
|
|
1376
1377
|
// configCacheVersion namespaces the on-disk config cache. Bump it whenever
|
|
1377
1378
|
// the shape of a cached config object changes so that entries written by an
|
|
1378
1379
|
// older @ttsc/lint binary are treated as a miss rather than silently reused.
|
|
1379
|
-
|
|
1380
|
+
// v6 adds the `entry` dependency kind. A v5 cache records a resolution trace's
|
|
1381
|
+
// ancestors as directory digests, so reusing it would keep republishing the
|
|
1382
|
+
// filesystem root as a watch input for as long as the entry survives.
|
|
1383
|
+
const configCacheVersion = "v6"
|
|
1380
1384
|
|
|
1381
1385
|
// configEvalCache memoizes evaluated .ts/.js lint config objects for the
|
|
1382
1386
|
// lifetime of one process; the on-disk cache (configCacheDir) extends the
|
|
@@ -1615,6 +1619,36 @@ func configDependencyDigest(
|
|
|
1615
1619
|
}
|
|
1616
1620
|
return hex.EncodeToString(h.Sum(nil)), nil
|
|
1617
1621
|
}
|
|
1622
|
+
// The `entry` digest observes one path's own existence and link topology.
|
|
1623
|
+
// It must reproduce the loader script's encoding byte for byte, because the
|
|
1624
|
+
// script writes the fingerprint and this function is what later decides the
|
|
1625
|
+
// cached evaluation is still current.
|
|
1626
|
+
if dependency.Kind == configDependencyEntry {
|
|
1627
|
+
info, err := os.Lstat(dependency.Path)
|
|
1628
|
+
if err != nil {
|
|
1629
|
+
digest := sha256.Sum256([]byte("missing\x00"))
|
|
1630
|
+
return hex.EncodeToString(digest[:]), nil
|
|
1631
|
+
}
|
|
1632
|
+
if info.Mode()&os.ModeSymlink != 0 {
|
|
1633
|
+
target, err := os.Readlink(dependency.Path)
|
|
1634
|
+
if err != nil {
|
|
1635
|
+
target = "<unreadable>"
|
|
1636
|
+
}
|
|
1637
|
+
h := sha256.New()
|
|
1638
|
+
h.Write([]byte("symlink\x00"))
|
|
1639
|
+
h.Write([]byte(target))
|
|
1640
|
+
return hex.EncodeToString(h.Sum(nil)), nil
|
|
1641
|
+
}
|
|
1642
|
+
kind := "other"
|
|
1643
|
+
switch {
|
|
1644
|
+
case info.IsDir():
|
|
1645
|
+
kind = "directory"
|
|
1646
|
+
case info.Mode().IsRegular():
|
|
1647
|
+
kind = "file"
|
|
1648
|
+
}
|
|
1649
|
+
digest := sha256.Sum256([]byte(kind + "\x00"))
|
|
1650
|
+
return hex.EncodeToString(digest[:]), nil
|
|
1651
|
+
}
|
|
1618
1652
|
if dependency.Kind == configDependencyOptionalFile {
|
|
1619
1653
|
info, err := os.Stat(dependency.Path)
|
|
1620
1654
|
if err != nil || !info.Mode().IsRegular() {
|
|
@@ -1836,6 +1870,7 @@ func normalizeConfigDependencyFingerprints(
|
|
|
1836
1870
|
strings.ToLower(dependency.Digest) != dependency.Digest ||
|
|
1837
1871
|
(dependency.Kind != configDependencyFile &&
|
|
1838
1872
|
dependency.Kind != configDependencyDir &&
|
|
1873
|
+
dependency.Kind != configDependencyEntry &&
|
|
1839
1874
|
dependency.Kind != configDependencyOptionalFile) ||
|
|
1840
1875
|
(dependency.Scope != configDependencyCache &&
|
|
1841
1876
|
dependency.Scope != configDependencyWatch) {
|
|
@@ -2139,6 +2174,55 @@ function recordDirectoryDependency(location, owners) {
|
|
|
2139
2174
|
}
|
|
2140
2175
|
}
|
|
2141
2176
|
|
|
2177
|
+
// Observe one path's own existence and link topology instead of enumerating the
|
|
2178
|
+
// directory that contains it. A resolution trace passes through ancestors it
|
|
2179
|
+
// does not own -- /var on macOS is a symlink whose parent is the filesystem
|
|
2180
|
+
// root -- and digesting that parent both reaches outside the project boundary
|
|
2181
|
+
// and reads the whole directory to learn one entry's state.
|
|
2182
|
+
// A path candidate is observed through the directory that would own a
|
|
2183
|
+
// competing resolution, so a sibling winning extension resolution still
|
|
2184
|
+
// invalidates. That reasoning is what the parent digest is for and it stays.
|
|
2185
|
+
//
|
|
2186
|
+
// It does not reach the filesystem root. The root owns no candidate this trace
|
|
2187
|
+
// could pick, and a resolution path routinely passes through an ancestor
|
|
2188
|
+
// directly beneath it -- /var on macOS is a symlink whose parent is the root --
|
|
2189
|
+
// so digesting the parent there enumerates the entire filesystem root on every
|
|
2190
|
+
// config load, outside the project boundary. Record that one ancestor instead.
|
|
2191
|
+
function recordAncestorDependency(parent, entry, root, owners) {
|
|
2192
|
+
if (parent === root) recordEntryDependency(entry, owners);
|
|
2193
|
+
else recordDirectoryDependency(parent, owners);
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
function recordEntryDependency(location, owners) {
|
|
2197
|
+
recordDependency("entry", location, entryDigest(location), owners);
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
function entryDigest(location) {
|
|
2201
|
+
let entry;
|
|
2202
|
+
try {
|
|
2203
|
+
entry = fs.lstatSync(location);
|
|
2204
|
+
} catch {
|
|
2205
|
+
return createHash("sha256").update("missing\0").digest("hex");
|
|
2206
|
+
}
|
|
2207
|
+
if (entry.isSymbolicLink()) {
|
|
2208
|
+
let target;
|
|
2209
|
+
try {
|
|
2210
|
+
target = fs.readlinkSync(location, { encoding: "buffer" });
|
|
2211
|
+
} catch {
|
|
2212
|
+
target = Buffer.from("<unreadable>");
|
|
2213
|
+
}
|
|
2214
|
+
return createHash("sha256")
|
|
2215
|
+
.update(Buffer.concat([Buffer.from("symlink\0"), target]))
|
|
2216
|
+
.digest("hex");
|
|
2217
|
+
}
|
|
2218
|
+
const kind = entry.isDirectory()
|
|
2219
|
+
? "directory"
|
|
2220
|
+
: entry.isFile()
|
|
2221
|
+
? "file"
|
|
2222
|
+
: "other";
|
|
2223
|
+
return createHash("sha256").update(kind + "\0").digest("hex");
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2142
2226
|
function directoryDigest(location) {
|
|
2143
2227
|
const entries = [];
|
|
2144
2228
|
if (process.platform === "win32") {
|
|
@@ -2691,11 +2775,11 @@ function recordPackagePathCandidate(
|
|
|
2691
2775
|
try {
|
|
2692
2776
|
entry = fs.lstatSync(next);
|
|
2693
2777
|
} catch {
|
|
2694
|
-
|
|
2778
|
+
recordAncestorDependency(current, next, parsed.root, owners);
|
|
2695
2779
|
return;
|
|
2696
2780
|
}
|
|
2697
2781
|
if (entry.isSymbolicLink()) {
|
|
2698
|
-
|
|
2782
|
+
recordAncestorDependency(current, next, parsed.root, owners);
|
|
2699
2783
|
try {
|
|
2700
2784
|
const target = fs.readlinkSync(next);
|
|
2701
2785
|
const remainder = components.slice(index + 1);
|
|
@@ -2717,16 +2801,20 @@ function recordPackagePathCandidate(
|
|
|
2717
2801
|
}
|
|
2718
2802
|
}
|
|
2719
2803
|
if (index === components.length - 1) {
|
|
2720
|
-
|
|
2804
|
+
if (isDirectory) recordDirectoryDependency(next, owners);
|
|
2805
|
+
else recordAncestorDependency(current, next, parsed.root, owners);
|
|
2721
2806
|
return;
|
|
2722
2807
|
}
|
|
2723
2808
|
if (!isDirectory) {
|
|
2724
|
-
|
|
2809
|
+
recordAncestorDependency(current, next, parsed.root, owners);
|
|
2725
2810
|
return;
|
|
2726
2811
|
}
|
|
2727
2812
|
current = next;
|
|
2728
2813
|
}
|
|
2729
|
-
|
|
2814
|
+
// Reached only when the candidate resolved to the filesystem root itself,
|
|
2815
|
+
// which names no package. Record its existence, not its listing.
|
|
2816
|
+
if (current === parsed.root) recordEntryDependency(current, owners);
|
|
2817
|
+
else recordDirectoryDependency(current, owners);
|
|
2730
2818
|
}
|
|
2731
2819
|
|
|
2732
2820
|
function modulePackageName(specifier) {
|
|
@@ -3045,7 +3133,7 @@ const resolutionRoot = path.resolve(%s);
|
|
|
3045
3133
|
const CONFIG_KEYS = new Set<string>([%s]);
|
|
3046
3134
|
const dependencies = new Map<string, {
|
|
3047
3135
|
digest: string;
|
|
3048
|
-
kind: "directory" | "file" | "optional-file";
|
|
3136
|
+
kind: "directory" | "entry" | "file" | "optional-file";
|
|
3049
3137
|
path: string;
|
|
3050
3138
|
owners: Set<string>;
|
|
3051
3139
|
}>();
|
|
@@ -3207,7 +3295,7 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
3207
3295
|
}
|
|
3208
3296
|
|
|
3209
3297
|
function recordDependency(
|
|
3210
|
-
kind: "directory" | "file" | "optional-file",
|
|
3298
|
+
kind: "directory" | "entry" | "file" | "optional-file",
|
|
3211
3299
|
location: string,
|
|
3212
3300
|
digest: string,
|
|
3213
3301
|
owners: readonly string[],
|
|
@@ -3271,6 +3359,63 @@ function recordDirectoryDependency(
|
|
|
3271
3359
|
}
|
|
3272
3360
|
}
|
|
3273
3361
|
|
|
3362
|
+
// Observe one path's own existence and link topology instead of enumerating the
|
|
3363
|
+
// directory that contains it. A resolution trace passes through ancestors it
|
|
3364
|
+
// does not own -- /var on macOS is a symlink whose parent is the filesystem
|
|
3365
|
+
// root -- and digesting that parent both reaches outside the project boundary
|
|
3366
|
+
// and reads the whole directory to learn one entry's state.
|
|
3367
|
+
// A path candidate is observed through the directory that would own a
|
|
3368
|
+
// competing resolution, so a sibling winning extension resolution still
|
|
3369
|
+
// invalidates. That reasoning is what the parent digest is for and it stays.
|
|
3370
|
+
//
|
|
3371
|
+
// It does not reach the filesystem root. The root owns no candidate this trace
|
|
3372
|
+
// could pick, and a resolution path routinely passes through an ancestor
|
|
3373
|
+
// directly beneath it -- /var on macOS is a symlink whose parent is the root --
|
|
3374
|
+
// so digesting the parent there enumerates the entire filesystem root on every
|
|
3375
|
+
// config load, outside the project boundary. Record that one ancestor instead.
|
|
3376
|
+
function recordAncestorDependency(
|
|
3377
|
+
parent: string,
|
|
3378
|
+
entry: string,
|
|
3379
|
+
root: string,
|
|
3380
|
+
owners: readonly string[],
|
|
3381
|
+
): void {
|
|
3382
|
+
if (parent === root) recordEntryDependency(entry, owners);
|
|
3383
|
+
else recordDirectoryDependency(parent, owners);
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
function recordEntryDependency(
|
|
3387
|
+
location: string,
|
|
3388
|
+
owners: readonly string[],
|
|
3389
|
+
): void {
|
|
3390
|
+
recordDependency("entry", location, entryDigest(location), owners);
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
function entryDigest(location: string): string {
|
|
3394
|
+
let entry: ReturnType<typeof fs.lstatSync>;
|
|
3395
|
+
try {
|
|
3396
|
+
entry = fs.lstatSync(location);
|
|
3397
|
+
} catch {
|
|
3398
|
+
return createHash("sha256").update("missing\0").digest("hex");
|
|
3399
|
+
}
|
|
3400
|
+
if (entry.isSymbolicLink()) {
|
|
3401
|
+
let target: Buffer;
|
|
3402
|
+
try {
|
|
3403
|
+
target = fs.readlinkSync(location, { encoding: "buffer" });
|
|
3404
|
+
} catch {
|
|
3405
|
+
target = Buffer.from("<unreadable>");
|
|
3406
|
+
}
|
|
3407
|
+
return createHash("sha256")
|
|
3408
|
+
.update(Buffer.concat([Buffer.from("symlink\0"), target]))
|
|
3409
|
+
.digest("hex");
|
|
3410
|
+
}
|
|
3411
|
+
const kind = entry.isDirectory()
|
|
3412
|
+
? "directory"
|
|
3413
|
+
: entry.isFile()
|
|
3414
|
+
? "file"
|
|
3415
|
+
: "other";
|
|
3416
|
+
return createHash("sha256").update(kind + "\0").digest("hex");
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3274
3419
|
function directoryDigest(location: string): string {
|
|
3275
3420
|
const entries: Buffer[] = [];
|
|
3276
3421
|
if (process.platform === "win32") {
|
|
@@ -3851,11 +3996,11 @@ function recordPackagePathCandidate(
|
|
|
3851
3996
|
try {
|
|
3852
3997
|
entry = fs.lstatSync(next);
|
|
3853
3998
|
} catch {
|
|
3854
|
-
|
|
3999
|
+
recordAncestorDependency(current, next, parsed.root, owners);
|
|
3855
4000
|
return;
|
|
3856
4001
|
}
|
|
3857
4002
|
if (entry.isSymbolicLink()) {
|
|
3858
|
-
|
|
4003
|
+
recordAncestorDependency(current, next, parsed.root, owners);
|
|
3859
4004
|
try {
|
|
3860
4005
|
const target = fs.readlinkSync(next);
|
|
3861
4006
|
const remainder = components.slice(index + 1);
|
|
@@ -3877,16 +4022,20 @@ function recordPackagePathCandidate(
|
|
|
3877
4022
|
}
|
|
3878
4023
|
}
|
|
3879
4024
|
if (index === components.length - 1) {
|
|
3880
|
-
|
|
4025
|
+
if (isDirectory) recordDirectoryDependency(next, owners);
|
|
4026
|
+
else recordAncestorDependency(current, next, parsed.root, owners);
|
|
3881
4027
|
return;
|
|
3882
4028
|
}
|
|
3883
4029
|
if (!isDirectory) {
|
|
3884
|
-
|
|
4030
|
+
recordAncestorDependency(current, next, parsed.root, owners);
|
|
3885
4031
|
return;
|
|
3886
4032
|
}
|
|
3887
4033
|
current = next;
|
|
3888
4034
|
}
|
|
3889
|
-
|
|
4035
|
+
// Reached only when the candidate resolved to the filesystem root itself,
|
|
4036
|
+
// which names no package. Record its existence, not its listing.
|
|
4037
|
+
if (current === parsed.root) recordEntryDependency(current, owners);
|
|
4038
|
+
else recordDirectoryDependency(current, owners);
|
|
3890
4039
|
}
|
|
3891
4040
|
|
|
3892
4041
|
function modulePackageName(specifier: string): string | undefined {
|
|
@@ -3964,7 +4113,7 @@ function realPath(location: string): string {
|
|
|
3964
4113
|
|
|
3965
4114
|
function finalizeDependencies(): Array<{
|
|
3966
4115
|
digest: string;
|
|
3967
|
-
kind: "directory" | "file" | "optional-file";
|
|
4116
|
+
kind: "directory" | "entry" | "file" | "optional-file";
|
|
3968
4117
|
path: string;
|
|
3969
4118
|
scope: "cache" | "watch";
|
|
3970
4119
|
}> {
|
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)
|
package/linthost/host.go
CHANGED
|
@@ -42,6 +42,15 @@ type program struct {
|
|
|
42
42
|
checker *shimchecker.Checker
|
|
43
43
|
identity publicrule.ProjectIdentity
|
|
44
44
|
projectCycle *projectCycle
|
|
45
|
+
// projectRoots memoizes projectSourceFileNames, which resolves every
|
|
46
|
+
// selected file's path and therefore costs filesystem work. The tsconfig
|
|
47
|
+
// selection cannot change while one program is loaded — a config edit or a
|
|
48
|
+
// new or removed file forces a full reload upstream rather than an
|
|
49
|
+
// applyChange — while every read and every write consults the set at least
|
|
50
|
+
// once per cycle, and a fix or format cascade repeats that per pass. Filled
|
|
51
|
+
// lazily under the same single-threaded assumption projectCycle already
|
|
52
|
+
// makes, and read-only to its callers.
|
|
53
|
+
projectRoots map[string]struct{}
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
type loadProgramOptions struct {
|
|
@@ -274,11 +283,38 @@ func (p *program) runProjectCycle(engine *Engine) *projectCycle {
|
|
|
274
283
|
return p.projectCycle
|
|
275
284
|
}
|
|
276
285
|
|
|
286
|
+
// runLintCycle walks everything the invocation reads: the project's own sources
|
|
287
|
+
// and the TypeScript it imported. Every caller here reports its findings, so a
|
|
288
|
+
// source the type-check pass read must be able to produce one.
|
|
277
289
|
func (p *program) runLintCycle(engine *Engine) []*Finding {
|
|
290
|
+
return p.runCycleOver(engine, p.userSourceFiles())
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// runWriteScopedCycle walks the project's own sources alone. It serves the
|
|
294
|
+
// commands that edit files and report nothing: `format` and the LSP document
|
|
295
|
+
// fix and format verbs.
|
|
296
|
+
//
|
|
297
|
+
// Such a command must not rewrite a sibling package it merely imports, and it
|
|
298
|
+
// prints no diagnostic, so a finding outside the project has nowhere to go.
|
|
299
|
+
// Reading wider would spend a full walk, once per cascade pass, on findings the
|
|
300
|
+
// command discards. Scope is enforced by what these commands read rather than
|
|
301
|
+
// by filtering afterwards, which leaves projectWritableFindings to `fix` alone.
|
|
302
|
+
func (p *program) runWriteScopedCycle(engine *Engine) []*Finding {
|
|
303
|
+
return p.runCycleOver(engine, p.projectSourceFiles())
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// runCycleOver evaluates the project rules and the file rules over one file set,
|
|
307
|
+
// memoizing the project cycle on the program so a second verb against the same
|
|
308
|
+
// program does not re-evaluate a rule. The caller owns the scope decision.
|
|
309
|
+
//
|
|
310
|
+
// That memo makes the scope a property of the program, not of the call: the
|
|
311
|
+
// first cycle fixes the population every later verb observes. One loaded
|
|
312
|
+
// program therefore serves one scope, and a caller must not ask the same
|
|
313
|
+
// program for both a lint cycle and a write-scoped one.
|
|
314
|
+
func (p *program) runCycleOver(engine *Engine, files []*shimast.SourceFile) []*Finding {
|
|
278
315
|
if p == nil || engine == nil {
|
|
279
316
|
return nil
|
|
280
317
|
}
|
|
281
|
-
files := p.userSourceFiles()
|
|
282
318
|
if p.projectCycle == nil {
|
|
283
319
|
p.projectCycle = engine.evaluateProject(p.identity, files, p.checker)
|
|
284
320
|
}
|
|
@@ -348,19 +384,59 @@ func (p *program) applyChange(absPath string) bool {
|
|
|
348
384
|
return reused
|
|
349
385
|
}
|
|
350
386
|
|
|
351
|
-
// userSourceFiles returns the
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
387
|
+
// userSourceFiles returns the source files the lint engine reads for one cycle:
|
|
388
|
+
// the tsconfig-selected TS/JS roots plus every TypeScript source the Program
|
|
389
|
+
// pulled in through an import.
|
|
390
|
+
//
|
|
391
|
+
// The tsconfig file list alone is not the boundary. `ttsc` type-checks a
|
|
392
|
+
// first-party sibling workspace package that resolves to its own `src`, so a
|
|
393
|
+
// reporting pass restricted to the file list would hold a second, narrower view
|
|
394
|
+
// of the single Program the invocation loaded — the file is checked but never
|
|
395
|
+
// linted, and never reaches a project rule's ctx.Sources (samchon/ttsc#1065).
|
|
396
|
+
// A consumer cannot close that gap from configuration either: adding the
|
|
397
|
+
// sibling to `include` also changes what the project emits.
|
|
398
|
+
//
|
|
399
|
+
// The widening admits authored TypeScript only — `.ts`, `.tsx`, `.mts`, `.cts`
|
|
400
|
+
// that are not declaration files. Everything else stays selection-driven:
|
|
401
|
+
// - a declaration file is typings rather than authored source, and the bundled
|
|
402
|
+
// `lib.*.d.ts` set plus every published package's `.d.ts` reach
|
|
403
|
+
// Program.SourceFiles() as well;
|
|
404
|
+
// - JavaScript enters the Program only under `allowJs`, where the project's own
|
|
405
|
+
// file list already selects the JS it owns;
|
|
406
|
+
// - a JSON module carries no lint source at all.
|
|
407
|
+
//
|
|
408
|
+
// A project that selects any of those explicitly keeps them, exactly as before.
|
|
409
|
+
// A published dependency reaches the Program through its typings, so its own
|
|
410
|
+
// `.ts` sources stay out without a dependency-shaped rule here.
|
|
356
411
|
func (p *program) userSourceFiles() []*shimast.SourceFile {
|
|
357
|
-
roots := p.
|
|
412
|
+
roots := p.projectSourceFileNames()
|
|
358
413
|
out := make([]*shimast.SourceFile, 0)
|
|
359
414
|
for _, f := range p.tsProgram.SourceFiles() {
|
|
360
415
|
if f == nil {
|
|
361
416
|
continue
|
|
362
417
|
}
|
|
363
|
-
if
|
|
418
|
+
if p.selectedByProject(roots, f.FileName()) {
|
|
419
|
+
out = append(out, f)
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
if isImportedLintSourceFile(f) {
|
|
423
|
+
out = append(out, f)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return out
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// projectSourceFiles returns the Program's copy of the files the tsconfig itself
|
|
430
|
+
// selected — the project's own sources, the set `format` walks and the set any
|
|
431
|
+
// lint write stays inside.
|
|
432
|
+
func (p *program) projectSourceFiles() []*shimast.SourceFile {
|
|
433
|
+
roots := p.projectSourceFileNames()
|
|
434
|
+
out := make([]*shimast.SourceFile, 0, len(roots))
|
|
435
|
+
for _, f := range p.tsProgram.SourceFiles() {
|
|
436
|
+
if f == nil {
|
|
437
|
+
continue
|
|
438
|
+
}
|
|
439
|
+
if !p.selectedByProject(roots, f.FileName()) {
|
|
364
440
|
continue
|
|
365
441
|
}
|
|
366
442
|
out = append(out, f)
|
|
@@ -368,15 +444,88 @@ func (p *program) userSourceFiles() []*shimast.SourceFile {
|
|
|
368
444
|
return out
|
|
369
445
|
}
|
|
370
446
|
|
|
371
|
-
|
|
447
|
+
// projectSourceFileNames returns the canonical paths of the TS/JS files the
|
|
448
|
+
// tsconfig itself selected, indexed under both the configured spelling and the
|
|
449
|
+
// resolved one.
|
|
450
|
+
//
|
|
451
|
+
// This is the narrow half of the boundary above. `format` reads nothing else at
|
|
452
|
+
// all, and `fix` reads wider but writes only here, because a project must not
|
|
453
|
+
// rewrite a sibling package's sources merely because it imports them. See
|
|
454
|
+
// projectWritableFindings.
|
|
455
|
+
//
|
|
456
|
+
// Both spellings are indexed because a project can be reached through a
|
|
457
|
+
// junction, a symlink, or a Windows 8.3 short name, and the Program need not
|
|
458
|
+
// report a file under the spelling the config used. Before the read scope
|
|
459
|
+
// widened, an alias mismatch merely dropped the file from every pass. Now it
|
|
460
|
+
// would leave the file readable and unwritable, turning a fixable diagnostic
|
|
461
|
+
// into one `fix` refuses to touch, so ownership resolves the alias.
|
|
462
|
+
func (p *program) projectSourceFileNames() map[string]struct{} {
|
|
463
|
+
if p == nil {
|
|
464
|
+
return map[string]struct{}{}
|
|
465
|
+
}
|
|
466
|
+
if p.projectRoots != nil {
|
|
467
|
+
return p.projectRoots
|
|
468
|
+
}
|
|
372
469
|
out := make(map[string]struct{})
|
|
373
|
-
if p
|
|
374
|
-
|
|
470
|
+
if p.parsed != nil && p.parsed.ParsedConfig != nil {
|
|
471
|
+
for _, fileName := range p.parsed.ParsedConfig.FileNames {
|
|
472
|
+
if !isLintSourceFileName(fileName) {
|
|
473
|
+
continue
|
|
474
|
+
}
|
|
475
|
+
absolute := absoluteProjectPath(p.cwd, fileName)
|
|
476
|
+
out[canonicalProjectPath(p.cwd, absolute)] = struct{}{}
|
|
477
|
+
out[canonicalProjectPath(p.cwd, realProjectPath(absolute))] = struct{}{}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
p.projectRoots = out
|
|
481
|
+
return out
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// selectedByProject reports whether the tsconfig selected fileName, resolving
|
|
485
|
+
// the path only when its own spelling misses.
|
|
486
|
+
//
|
|
487
|
+
// Indexing both spellings above already catches the ordinary link, so this
|
|
488
|
+
// fallback exists for a Program spelling that matches neither, such as a
|
|
489
|
+
// Windows 8.3 short name. It costs one resolution per file the config did not
|
|
490
|
+
// select, paid by the imported set on every cycle, against the rule walk those
|
|
491
|
+
// same files are about to receive.
|
|
492
|
+
func (p *program) selectedByProject(
|
|
493
|
+
roots map[string]struct{},
|
|
494
|
+
fileName string,
|
|
495
|
+
) bool {
|
|
496
|
+
if p == nil || len(roots) == 0 {
|
|
497
|
+
return false
|
|
375
498
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
499
|
+
if _, ok := roots[canonicalProjectPath(p.cwd, fileName)]; ok {
|
|
500
|
+
return true
|
|
501
|
+
}
|
|
502
|
+
resolved := realProjectPath(absoluteProjectPath(p.cwd, fileName))
|
|
503
|
+
_, ok := roots[canonicalProjectPath(p.cwd, resolved)]
|
|
504
|
+
return ok
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// projectWritableFindings keeps the findings whose file this project may write:
|
|
508
|
+
// the ones sitting in a tsconfig-selected source, dropping every edit aimed at a
|
|
509
|
+
// source the Program reached only through an import.
|
|
510
|
+
//
|
|
511
|
+
// This is `fix`'s guard alone, because `fix` is the one command that must read
|
|
512
|
+
// wider than it writes: it prints the diagnostics that survive the cascade, so
|
|
513
|
+
// an imported source has to reach its report while its edit must not reach
|
|
514
|
+
// disk. The package that owns the file fixes it from its own run, under its own
|
|
515
|
+
// config. Commands that only write walk the narrow set to begin with. A finding
|
|
516
|
+
// without a source file (a project rule's detached report) never reaches disk
|
|
517
|
+
// either and is dropped with them.
|
|
518
|
+
func (p *program) projectWritableFindings(findings []*Finding) []*Finding {
|
|
519
|
+
roots := p.projectSourceFileNames()
|
|
520
|
+
out := make([]*Finding, 0, len(findings))
|
|
521
|
+
for _, finding := range findings {
|
|
522
|
+
if finding == nil || finding.File == nil {
|
|
523
|
+
continue
|
|
379
524
|
}
|
|
525
|
+
if !p.selectedByProject(roots, finding.File.FileName()) {
|
|
526
|
+
continue
|
|
527
|
+
}
|
|
528
|
+
out = append(out, finding)
|
|
380
529
|
}
|
|
381
530
|
return out
|
|
382
531
|
}
|
|
@@ -388,6 +537,18 @@ func canonicalProjectPath(cwd, fileName string) string {
|
|
|
388
537
|
return filepath.ToSlash(filepath.Clean(fileName))
|
|
389
538
|
}
|
|
390
539
|
|
|
540
|
+
// isImportedLintSourceFile reports whether a Program source file the tsconfig
|
|
541
|
+
// did not select is authored TypeScript the lint pass must still read.
|
|
542
|
+
func isImportedLintSourceFile(file *shimast.SourceFile) bool {
|
|
543
|
+
if file == nil || file.IsDeclarationFile {
|
|
544
|
+
return false
|
|
545
|
+
}
|
|
546
|
+
return isTypeScriptSourceFileName(file.FileName())
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// isLintSourceFileName reports whether a tsconfig-selected file is a lint/format
|
|
550
|
+
// source root. The project's own selection governs here, so both TypeScript and
|
|
551
|
+
// JavaScript qualify: a project that lists `.js` under `allowJs` owns it.
|
|
391
552
|
func isLintSourceFileName(fileName string) bool {
|
|
392
553
|
switch strings.ToLower(filepath.Ext(fileName)) {
|
|
393
554
|
case ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs":
|
|
@@ -397,6 +558,18 @@ func isLintSourceFileName(fileName string) bool {
|
|
|
397
558
|
}
|
|
398
559
|
}
|
|
399
560
|
|
|
561
|
+
// isTypeScriptSourceFileName reports whether a path names TypeScript source.
|
|
562
|
+
// `.d.ts` shares the `.ts` extension, so callers pair this with the source
|
|
563
|
+
// file's IsDeclarationFile flag rather than reading the suffix twice.
|
|
564
|
+
func isTypeScriptSourceFileName(fileName string) bool {
|
|
565
|
+
switch strings.ToLower(filepath.Ext(fileName)) {
|
|
566
|
+
case ".ts", ".tsx", ".mts", ".cts":
|
|
567
|
+
return true
|
|
568
|
+
default:
|
|
569
|
+
return false
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
400
573
|
// programDiagnostics returns the bind + semantic diagnostics for the
|
|
401
574
|
// loaded program. Same surface tsgo's CLI prints when you run a regular
|
|
402
575
|
// `tsgo --noEmit`.
|
package/linthost/lsp.go
CHANGED
|
@@ -981,7 +981,11 @@ func lspWorkspaceEditForSeededCommand(
|
|
|
981
981
|
prog.close()
|
|
982
982
|
return nil, 0
|
|
983
983
|
}
|
|
984
|
-
|
|
984
|
+
// This command edits a document, so it walks the project's own sources the
|
|
985
|
+
// way `format` does. Reading the imported TypeScript the lint cycle covers
|
|
986
|
+
// would widen nothing here: the edit is bounded to one target below, and a
|
|
987
|
+
// read-scope widening must not open a write the project never had.
|
|
988
|
+
findings := filterFindingsForPath(prog.runWriteScopedCycle(engine), tempTarget)
|
|
985
989
|
prog.close()
|
|
986
990
|
if opts.command == commandFormatDocument {
|
|
987
991
|
findings = filterFormatFindings(findings)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ttsc/lint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Reference ttsc plugin: ESLint-style lint rules over the TypeScript-Go Program used by the type-check pass.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"@types/node": "^25.3.0",
|
|
38
38
|
"rimraf": "^6.1.2",
|
|
39
39
|
"typescript": "^7.0.2",
|
|
40
|
-
"ttsc": "0.
|
|
40
|
+
"ttsc": "0.24.0"
|
|
41
41
|
},
|
|
42
42
|
"repository": {
|
|
43
43
|
"type": "git",
|
package/rule/project.go
CHANGED
|
@@ -170,7 +170,18 @@ type ProjectReporter interface {
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
// ProjectContext contains the immutable inputs for one project-rule check.
|
|
173
|
-
//
|
|
173
|
+
//
|
|
174
|
+
// Sources is a defensive copy of the user sources the host read for this cycle:
|
|
175
|
+
// the project's own tsconfig-selected files plus every TypeScript source the
|
|
176
|
+
// Program pulled in through an import, minus globally ignored paths. A rule
|
|
177
|
+
// that declares a population by glob therefore sees a first-party sibling
|
|
178
|
+
// workspace package the same way the type-check pass does, instead of an empty
|
|
179
|
+
// population that would silently report full coverage.
|
|
180
|
+
//
|
|
181
|
+
// A format run is the exception. It writes files and reports nothing, so it
|
|
182
|
+
// walks the project's own file list alone and a rule evaluated there receives
|
|
183
|
+
// that narrower population. Draw a conclusion that must hold across the
|
|
184
|
+
// workspace from a lint or check run.
|
|
174
185
|
type ProjectContext struct {
|
|
175
186
|
Identity ProjectIdentity
|
|
176
187
|
Sources []*shimast.SourceFile
|