@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 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 tsconfig-selected user-source set, including an empty set.
1173
- // ctx.Checker is the Program checker, and ctx.Identity keeps logical and
1174
- // physical project paths separate.
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
  }
@@ -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
- const configCacheVersion = "v5"
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
- recordDirectoryDependency(current, owners);
2778
+ recordAncestorDependency(current, next, parsed.root, owners);
2695
2779
  return;
2696
2780
  }
2697
2781
  if (entry.isSymbolicLink()) {
2698
- recordDirectoryDependency(current, owners);
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
- recordDirectoryDependency(isDirectory ? next : current, owners);
2804
+ if (isDirectory) recordDirectoryDependency(next, owners);
2805
+ else recordAncestorDependency(current, next, parsed.root, owners);
2721
2806
  return;
2722
2807
  }
2723
2808
  if (!isDirectory) {
2724
- recordDirectoryDependency(current, owners);
2809
+ recordAncestorDependency(current, next, parsed.root, owners);
2725
2810
  return;
2726
2811
  }
2727
2812
  current = next;
2728
2813
  }
2729
- recordDirectoryDependency(current, owners);
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
- recordDirectoryDependency(current, owners);
3999
+ recordAncestorDependency(current, next, parsed.root, owners);
3855
4000
  return;
3856
4001
  }
3857
4002
  if (entry.isSymbolicLink()) {
3858
- recordDirectoryDependency(current, owners);
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
- recordDirectoryDependency(isDirectory ? next : current, owners);
4025
+ if (isDirectory) recordDirectoryDependency(next, owners);
4026
+ else recordAncestorDependency(current, next, parsed.root, owners);
3881
4027
  return;
3882
4028
  }
3883
4029
  if (!isDirectory) {
3884
- recordDirectoryDependency(current, owners);
4030
+ recordAncestorDependency(current, next, parsed.root, owners);
3885
4031
  return;
3886
4032
  }
3887
4033
  current = next;
3888
4034
  }
3889
- recordDirectoryDependency(current, owners);
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(opts.cwd, findings)
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
@@ -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
- findings := prog.runLintCycle(engine)
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 tsconfig-selected source files the lint engine
352
- // owns. The tsconfig file list is the boundary: imported libraries, generated
353
- // output, and JSON modules may still appear in Program.SourceFiles(), but lint
354
- // and format should not walk them unless the project selected them as TS/JS
355
- // source roots.
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.userSourceFileNames()
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 _, ok := roots[canonicalProjectPath(p.cwd, f.FileName())]; !ok {
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
- func (p *program) userSourceFileNames() map[string]struct{} {
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 == nil || p.parsed == nil || p.parsed.ParsedConfig == nil {
374
- return out
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
- for _, fileName := range p.parsed.ParsedConfig.FileNames {
377
- if isLintSourceFileName(fileName) {
378
- out[canonicalProjectPath(p.cwd, fileName)] = struct{}{}
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
- findings := filterFindingsForPath(prog.runLintCycle(engine), tempTarget)
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.23.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.23.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
- // Sources is a defensive copy of the Program's tsconfig-selected user sources.
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