@ttsc/lint 0.26.2 → 0.28.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.
@@ -1294,10 +1294,12 @@ func loadConfigFile(location string) (any, error) {
1294
1294
  }
1295
1295
 
1296
1296
  type configDependencyFingerprint struct {
1297
- Path string `json:"path"`
1298
- Digest string `json:"digest"`
1299
- Kind string `json:"kind"`
1300
- Scope string `json:"scope"`
1297
+ Path string `json:"path"`
1298
+ Digest string `json:"digest"`
1299
+ IdentityStable bool `json:"identityStable"`
1300
+ Kind string `json:"kind"`
1301
+ Realpath *string `json:"realpath"`
1302
+ Scope string `json:"scope"`
1301
1303
  }
1302
1304
 
1303
1305
  const (
@@ -1367,10 +1369,10 @@ func loadConfigFileEvaluationWithin(
1367
1369
  // configCacheVersion namespaces the on-disk config cache. Bump it whenever
1368
1370
  // the shape of a cached config object changes so that entries written by an
1369
1371
  // older @ttsc/lint binary are treated as a miss rather than silently reused.
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"
1372
+ // v8 also rejects content-restoring A-B-A replacement during evaluation. A v7
1373
+ // cache can otherwise pair output from the transient state with the restored
1374
+ // state's equal digest and physical path.
1375
+ const configCacheVersion = "v8"
1374
1376
 
1375
1377
  // configEvalCache memoizes evaluated .ts/.js lint config objects for the
1376
1378
  // lifetime of one process; the on-disk cache (configCacheDir) extends the
@@ -1557,6 +1559,10 @@ func configDependencyDigestsAreCurrent(
1557
1559
  dependencies []configDependencyFingerprint,
1558
1560
  ) bool {
1559
1561
  for _, dependency := range dependencies {
1562
+ if !dependency.IdentityStable ||
1563
+ !sameConfigDependencyRealpath(dependency.Realpath, configDependencyRealpath(dependency.Path)) {
1564
+ return false
1565
+ }
1560
1566
  digest, err := configDependencyDigest(dependency)
1561
1567
  if err != nil {
1562
1568
  return false
@@ -1568,6 +1574,19 @@ func configDependencyDigestsAreCurrent(
1568
1574
  return true
1569
1575
  }
1570
1576
 
1577
+ func configDependencyRealpath(location string) *string {
1578
+ absolute, err := filepath.Abs(location)
1579
+ if err != nil {
1580
+ return nil
1581
+ }
1582
+ resolved := realProjectPath(absolute)
1583
+ if _, statErr := os.Stat(resolved); statErr != nil {
1584
+ return nil
1585
+ }
1586
+ resolved = filepath.Clean(resolved)
1587
+ return &resolved
1588
+ }
1589
+
1571
1590
  func configDependencyDigest(
1572
1591
  dependency configDependencyFingerprint,
1573
1592
  ) (string, error) {
@@ -1848,7 +1867,8 @@ func normalizeConfigDependencyFingerprints(
1848
1867
  for _, dependency := range input {
1849
1868
  if strings.TrimSpace(dependency.Path) == "" ||
1850
1869
  !filepath.IsAbs(dependency.Path) ||
1851
- len(dependency.Digest) != sha256.Size*2 ||
1870
+ (dependency.Realpath != nil && !filepath.IsAbs(*dependency.Realpath)) ||
1871
+ (dependency.Digest != "" && len(dependency.Digest) != sha256.Size*2) ||
1852
1872
  strings.ToLower(dependency.Digest) != dependency.Digest ||
1853
1873
  (dependency.Kind != configDependencyFile &&
1854
1874
  dependency.Kind != configDependencyDir &&
@@ -1858,24 +1878,30 @@ func normalizeConfigDependencyFingerprints(
1858
1878
  dependency.Scope != configDependencyWatch) {
1859
1879
  return nil, false
1860
1880
  }
1861
- if _, err := hex.DecodeString(dependency.Digest); err != nil {
1862
- return nil, false
1881
+ if dependency.Digest != "" {
1882
+ if _, err := hex.DecodeString(dependency.Digest); err != nil {
1883
+ return nil, false
1884
+ }
1863
1885
  }
1864
1886
  absolute := filepath.Clean(dependency.Path)
1865
1887
  key := dependency.Kind + "\x00" + absolute
1866
1888
  if previous, exists := seen[key]; exists {
1867
1889
  if previous.Digest != dependency.Digest ||
1890
+ previous.IdentityStable != dependency.IdentityStable ||
1868
1891
  previous.Kind != dependency.Kind ||
1892
+ !sameConfigDependencyRealpath(previous.Realpath, dependency.Realpath) ||
1869
1893
  previous.Scope != dependency.Scope {
1870
1894
  return nil, false
1871
1895
  }
1872
1896
  continue
1873
1897
  }
1874
1898
  fingerprint := configDependencyFingerprint{
1875
- Path: absolute,
1876
- Digest: dependency.Digest,
1877
- Kind: dependency.Kind,
1878
- Scope: dependency.Scope,
1899
+ Path: absolute,
1900
+ Digest: dependency.Digest,
1901
+ IdentityStable: dependency.IdentityStable,
1902
+ Kind: dependency.Kind,
1903
+ Realpath: cloneConfigDependencyRealpath(dependency.Realpath),
1904
+ Scope: dependency.Scope,
1879
1905
  }
1880
1906
  seen[key] = fingerprint
1881
1907
  normalized = append(normalized, fingerprint)
@@ -1886,6 +1912,21 @@ func normalizeConfigDependencyFingerprints(
1886
1912
  return normalized, true
1887
1913
  }
1888
1914
 
1915
+ func cloneConfigDependencyRealpath(value *string) *string {
1916
+ if value == nil {
1917
+ return nil
1918
+ }
1919
+ cloned := filepath.Clean(*value)
1920
+ return &cloned
1921
+ }
1922
+
1923
+ func sameConfigDependencyRealpath(left, right *string) bool {
1924
+ if left == nil || right == nil {
1925
+ return left == nil && right == nil
1926
+ }
1927
+ return filepath.Clean(*left) == filepath.Clean(*right)
1928
+ }
1929
+
1889
1930
  // loadScriptConfigFile evaluates a .js/.cjs/.mjs config file by running a
1890
1931
  // Node subprocess that dynamic-imports the file, resolves the exported config
1891
1932
  // through the same 8-hop default/config normalization used by the TS loader,
@@ -1919,12 +1960,18 @@ func loadScriptConfigEvaluationWithin(
1919
1960
  cmd := exec.CommandContext(
1920
1961
  ctx,
1921
1962
  node,
1922
- "-e",
1923
- script,
1963
+ "--input-type=commonjs",
1964
+ "-",
1924
1965
  location,
1925
1966
  outputPath,
1926
1967
  resolutionRoot,
1927
1968
  )
1969
+ // Windows limits the whole process command line to roughly 32 KiB. The
1970
+ // dependency-tracking loader is intentionally larger than that, so keep only
1971
+ // an explicit CommonJS stdin program and remove Node's stdin sentinel before
1972
+ // the loader runs. This preserves the historical process.argv layout seen by
1973
+ // both the loader and the imported user config without using string eval.
1974
+ cmd.Stdin = strings.NewReader("process.argv.splice(1, 1);\n" + script)
1928
1975
  return runConfigLoaderCommand(cmd, location, "config file", outputPath)
1929
1976
  }
1930
1977
 
@@ -2104,19 +2151,78 @@ function isObject(value) {
2104
2151
  return value !== null && typeof value === "object";
2105
2152
  }
2106
2153
 
2154
+ function missingPathError(error) {
2155
+ return error && (error.code === "ENOENT" || error.code === "ENOTDIR");
2156
+ }
2157
+
2158
+ function dependencyMetadataSignature(location) {
2159
+ const requested = path.resolve(location);
2160
+ let current = requested;
2161
+ for (;;) {
2162
+ try {
2163
+ const link = fs.lstatSync(current, { bigint: true });
2164
+ let target = link;
2165
+ if (link.isSymbolicLink()) {
2166
+ try { target = fs.statSync(current, { bigint: true }); }
2167
+ catch { return undefined; }
2168
+ }
2169
+ return [path.relative(current, requested), link.dev, link.ino, link.mode, link.size, link.mtimeNs, link.ctimeNs, target.dev, target.ino, target.mode, target.size, target.mtimeNs, target.ctimeNs].join(":");
2170
+ } catch (error) {
2171
+ if (!missingPathError(error)) return undefined;
2172
+ const parent = path.dirname(current);
2173
+ if (parent === current) return undefined;
2174
+ current = parent;
2175
+ }
2176
+ }
2177
+ }
2178
+
2179
+ function currentDependencyDigest(kind, location) {
2180
+ try {
2181
+ if (kind === "directory") return directoryDigest(location);
2182
+ if (kind === "entry") return entryDigest(location);
2183
+ if (kind === "optional-file") return optionalFileDigest(location);
2184
+ return createHash("sha256").update(fs.readFileSync(location)).digest("hex");
2185
+ } catch {
2186
+ return "";
2187
+ }
2188
+ }
2189
+
2107
2190
  function recordDependency(kind, location, digest, owners) {
2108
2191
  const key = kind + "\0" + location;
2109
2192
  const previous = dependencies.get(key);
2110
2193
  const mergedOwners = previous ? previous.owners : new Set();
2111
2194
  for (const owner of owners) mergedOwners.add(owner);
2195
+ const beforeSignature = dependencyMetadataSignature(location);
2196
+ const observedDigest = currentDependencyDigest(kind, location);
2197
+ const realpath = dependencyRealpath(location);
2198
+ const afterSignature = dependencyMetadataSignature(location);
2199
+ const identityStable =
2200
+ (!previous || previous.identityStable) &&
2201
+ beforeSignature !== undefined &&
2202
+ afterSignature !== undefined &&
2203
+ beforeSignature === afterSignature &&
2204
+ digest === observedDigest &&
2205
+ (!previous || previous.realpath === realpath) &&
2206
+ (!previous || previous.signature === afterSignature);
2112
2207
  dependencies.set(key, {
2113
- digest: previous && previous.digest !== digest ? "" : digest,
2208
+ digest: !identityStable || (previous && previous.digest !== digest) ? "" : digest,
2209
+ identityStable,
2114
2210
  kind,
2115
2211
  owners: mergedOwners,
2116
2212
  path: location,
2213
+ realpath,
2214
+ signature: afterSignature,
2117
2215
  });
2118
2216
  }
2119
2217
 
2218
+ function dependencyRealpath(location) {
2219
+ try {
2220
+ return realPath(location);
2221
+ } catch {
2222
+ return null;
2223
+ }
2224
+ }
2225
+
2120
2226
  function isLocalModuleSpecifier(specifier) {
2121
2227
  return specifier.startsWith(".") ||
2122
2228
  specifier.startsWith("/") ||
@@ -2874,10 +2980,22 @@ function realPath(location) {
2874
2980
  }
2875
2981
 
2876
2982
  function finalizeDependencies() {
2983
+ for (const dependency of [...dependencies.values()]) {
2984
+ recordDependency(
2985
+ dependency.kind,
2986
+ dependency.path,
2987
+ currentDependencyDigest(dependency.kind, dependency.path),
2988
+ [...dependency.owners],
2989
+ );
2990
+ }
2877
2991
  const watched = graphWatchReachability();
2878
- return [...dependencies.values()].map(({ owners, ...dependency }) => ({
2879
- ...dependency,
2880
- scope: [...owners].some((owner) => watched.has(owner))
2992
+ return [...dependencies.values()].map((dependency) => ({
2993
+ digest: dependency.digest,
2994
+ identityStable: dependency.identityStable,
2995
+ kind: dependency.kind,
2996
+ path: dependency.path,
2997
+ realpath: dependency.realpath,
2998
+ scope: [...dependency.owners].some((owner) => watched.has(owner))
2881
2999
  ? "watch"
2882
3000
  : "cache",
2883
3001
  }));
@@ -3121,9 +3239,12 @@ const resolutionRoot = path.resolve(%s);
3121
3239
  const CONFIG_KEYS = new Set<string>([%s]);
3122
3240
  const dependencies = new Map<string, {
3123
3241
  digest: string;
3242
+ identityStable: boolean;
3124
3243
  kind: "directory" | "entry" | "file" | "optional-file";
3125
3244
  path: string;
3126
3245
  owners: Set<string>;
3246
+ realpath: string | null;
3247
+ signature: string | undefined;
3127
3248
  }>();
3128
3249
  const graphNodes = new Map<string, string>();
3129
3250
  const graphEdges: Array<{
@@ -3296,6 +3417,48 @@ function isObject(value: unknown): value is Record<string, unknown> {
3296
3417
  return value !== null && typeof value === "object";
3297
3418
  }
3298
3419
 
3420
+ function missingPathError(error: unknown): boolean {
3421
+ const code = (error as { code?: unknown } | undefined)?.code;
3422
+ return code === "ENOENT" || code === "ENOTDIR";
3423
+ }
3424
+
3425
+ function dependencyMetadataSignature(
3426
+ location: string,
3427
+ ): string | undefined {
3428
+ const requested = path.resolve(location);
3429
+ let current = requested;
3430
+ for (;;) {
3431
+ try {
3432
+ const link = fs.lstatSync(current, { bigint: true });
3433
+ let target = link;
3434
+ if (link.isSymbolicLink()) {
3435
+ try { target = fs.statSync(current, { bigint: true }); }
3436
+ catch { return undefined; }
3437
+ }
3438
+ return [path.relative(current, requested), link.dev, link.ino, link.mode, link.size, link.mtimeNs, link.ctimeNs, target.dev, target.ino, target.mode, target.size, target.mtimeNs, target.ctimeNs].join(":");
3439
+ } catch (error) {
3440
+ if (!missingPathError(error)) return undefined;
3441
+ const parent = path.dirname(current);
3442
+ if (parent === current) return undefined;
3443
+ current = parent;
3444
+ }
3445
+ }
3446
+ }
3447
+
3448
+ function currentDependencyDigest(
3449
+ kind: "directory" | "entry" | "file" | "optional-file",
3450
+ location: string,
3451
+ ): string {
3452
+ try {
3453
+ if (kind === "directory") return directoryDigest(location);
3454
+ if (kind === "entry") return entryDigest(location);
3455
+ if (kind === "optional-file") return optionalFileDigest(location);
3456
+ return createHash("sha256").update(fs.readFileSync(location)).digest("hex");
3457
+ } catch {
3458
+ return "";
3459
+ }
3460
+ }
3461
+
3299
3462
  function recordDependency(
3300
3463
  kind: "directory" | "entry" | "file" | "optional-file",
3301
3464
  location: string,
@@ -3306,14 +3469,41 @@ function recordDependency(
3306
3469
  const previous = dependencies.get(key);
3307
3470
  const mergedOwners = previous?.owners ?? new Set<string>();
3308
3471
  for (const owner of owners) mergedOwners.add(owner);
3472
+ const beforeSignature = dependencyMetadataSignature(location);
3473
+ const observedDigest = currentDependencyDigest(kind, location);
3474
+ const realpath = dependencyRealpath(location);
3475
+ const afterSignature = dependencyMetadataSignature(location);
3476
+ const identityStable =
3477
+ previous?.identityStable !== false &&
3478
+ beforeSignature !== undefined &&
3479
+ afterSignature !== undefined &&
3480
+ beforeSignature === afterSignature &&
3481
+ digest === observedDigest &&
3482
+ (previous === undefined || previous.realpath === realpath) &&
3483
+ (previous === undefined || previous.signature === afterSignature);
3309
3484
  dependencies.set(key, {
3310
- digest: previous !== undefined && previous.digest !== digest ? "" : digest,
3485
+ digest:
3486
+ !identityStable ||
3487
+ (previous !== undefined && previous.digest !== digest)
3488
+ ? ""
3489
+ : digest,
3490
+ identityStable,
3311
3491
  kind,
3312
3492
  owners: mergedOwners,
3313
3493
  path: location,
3494
+ realpath,
3495
+ signature: afterSignature,
3314
3496
  });
3315
3497
  }
3316
3498
 
3499
+ function dependencyRealpath(location: string): string | null {
3500
+ try {
3501
+ return realPath(location);
3502
+ } catch {
3503
+ return null;
3504
+ }
3505
+ }
3506
+
3317
3507
  function isLocalModuleSpecifier(specifier: string): boolean {
3318
3508
  return specifier.startsWith(".") ||
3319
3509
  specifier.startsWith("/") ||
@@ -4115,10 +4305,20 @@ function realPath(location: string): string {
4115
4305
 
4116
4306
  function finalizeDependencies(): Array<{
4117
4307
  digest: string;
4308
+ identityStable: boolean;
4118
4309
  kind: "directory" | "entry" | "file" | "optional-file";
4119
4310
  path: string;
4311
+ realpath: string | null;
4120
4312
  scope: "cache" | "watch";
4121
4313
  }> {
4314
+ for (const dependency of [...dependencies.values()]) {
4315
+ recordDependency(
4316
+ dependency.kind,
4317
+ dependency.path,
4318
+ currentDependencyDigest(dependency.kind, dependency.path),
4319
+ [...dependency.owners],
4320
+ );
4321
+ }
4122
4322
  const watched = graphWatchReachability();
4123
4323
  // Opt-in diagnostics for a graph that comes back empty. The only channel this
4124
4324
  // loader may use is stderr, because the result travels through a private file
@@ -4136,9 +4336,13 @@ function finalizeDependencies(): Array<{
4136
4336
  "\n",
4137
4337
  );
4138
4338
  }
4139
- return [...dependencies.values()].map(({ owners, ...dependency }) => ({
4140
- ...dependency,
4141
- scope: [...owners].some((owner) => watched.has(owner))
4339
+ return [...dependencies.values()].map((dependency) => ({
4340
+ digest: dependency.digest,
4341
+ identityStable: dependency.identityStable,
4342
+ kind: dependency.kind,
4343
+ path: dependency.path,
4344
+ realpath: dependency.realpath,
4345
+ scope: [...dependency.owners].some((owner) => watched.has(owner))
4142
4346
  ? "watch"
4143
4347
  : "cache",
4144
4348
  }));
@@ -4251,6 +4455,7 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
4251
4455
  // resolving either way.
4252
4456
  "module": configModuleOption(location),
4253
4457
  "moduleResolution": "bundler",
4458
+ "jsx": "preserve",
4254
4459
  "noImplicitAny": false,
4255
4460
  "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
4256
4461
  "rewriteRelativeImportExtensions": true,
@@ -76,7 +76,7 @@ func run(args []string) int {
76
76
  // Don't pay contributor-registration cost for the version banner.
77
77
  fmt.Fprintf(os.Stdout, "@ttsc/lint %s\n", Version)
78
78
  return 0
79
- case "check", "check-serve", "fix", "format", "build", "transform", "project-inputs", "lsp-command-ids", "lsp-code-action-kinds", "lsp-diagnostics", "lsp-project-diagnostics", "lsp-code-actions", "lsp-execute-command", "lsp-hints", "lsp-serve":
79
+ case "check", "check-serve", "fix", "format", "build", "transform", "project-inputs", "lsp-command-ids", "lsp-code-action-kinds", "lsp-diagnostics", "lsp-project-diagnostics", "lsp-code-actions", "lsp-execute-command", "lsp-hints", "graph-nodes", "lsp-serve":
80
80
  default:
81
81
  fmt.Fprintf(os.Stderr, "@ttsc/lint: unknown command %q\n", args[0])
82
82
  return 2
@@ -113,6 +113,8 @@ func run(args []string) int {
113
113
  return RunLSPExecuteCommand(args[1:])
114
114
  case "lsp-hints":
115
115
  return RunLSPHints(args[1:])
116
+ case "graph-nodes":
117
+ return RunGraphNodes(args[1:])
116
118
  case "lsp-serve":
117
119
  return RunLSPServe(os.Stdin, os.Stdout, args[1:])
118
120
  }
@@ -0,0 +1,205 @@
1
+ package linthost
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "slices"
7
+
8
+ publicrule "github.com/samchon/ttsc/packages/lint/rule"
9
+ )
10
+
11
+ // RunGraphNodes prints the artifacts every project rule materialized for this
12
+ // project, as JSON.
13
+ //
14
+ // Like the hints verb this takes no `--uri`: a set of artifacts describes the
15
+ // Program, not a document. It loads a Program only when the resolved config
16
+ // declares a rule that can publish one, and a caller is expected to cache the
17
+ // answer and ask again only when the project's inputs changed.
18
+ //
19
+ // An empty set is a successful answer. A project with no publishing rule is the
20
+ // common case, and a caller must be able to tell it apart from a failure; a
21
+ // nonzero exit here would read as "the project is broken".
22
+ func RunGraphNodes(args []string) int {
23
+ opts, ok := parseLSPCommandOptions("graph-nodes", args)
24
+ if !ok {
25
+ return 2
26
+ }
27
+ nodes, code := computeGraphNodes(opts)
28
+ if code != 0 {
29
+ return code
30
+ }
31
+ return writeJSON(nodes)
32
+ }
33
+
34
+ // computeGraphNodes builds the artifact set for one project. Split from
35
+ // RunGraphNodes so a resident loop can reuse it over a warm Program, the same
36
+ // split computeLSPHints takes.
37
+ func computeGraphNodes(opts *lspCommandOptions) ([]publicrule.GraphNode, int) {
38
+ rules, err := acquireRules(opts.pluginsJSON, opts.cwd, opts.tsconfig)
39
+ if err != nil {
40
+ fmt.Fprintln(os.Stderr, err)
41
+ return nil, 2
42
+ }
43
+ engine := NewEngineWithResolver(rules)
44
+ if err := engine.ConfigError(); err != nil {
45
+ fmt.Fprintln(os.Stderr, err)
46
+ return nil, 2
47
+ }
48
+ publishes, needsChecker := engine.hasGraphPublisher()
49
+ if !publishes {
50
+ // Nothing the config declared can publish artifacts, so there is no
51
+ // projection to take and the Program is never built. This is what keeps a
52
+ // project that does not use the convention paying nothing for the verb.
53
+ return []publicrule.GraphNode{}, 0
54
+ }
55
+ prog, parseDiags, closeProgram, err := acquireProgram(opts, needsChecker)
56
+ if closeProgram != nil {
57
+ defer closeProgram()
58
+ }
59
+ if err != nil {
60
+ fmt.Fprintf(os.Stderr, "@ttsc/lint: %v\n", err)
61
+ return nil, 2
62
+ }
63
+ if prog == nil || len(parseDiags) > 0 {
64
+ // The project does not parse right now. Rules never ran, so there are no
65
+ // artifacts — but these are tsgo's diagnostics to own, and failing here
66
+ // would make a consumer treat a syntax error mid-typing as a broken plugin.
67
+ return []publicrule.GraphNode{}, 0
68
+ }
69
+ return collectProjectGraphNodes(prog.runProjectCycle(engine)), 0
70
+ }
71
+
72
+ // hasGraphPublisher reports whether any declared project rule can publish
73
+ // artifacts, and whether serving those rules needs a type checker.
74
+ //
75
+ // Both answers come from the registration table and the resolved config, so
76
+ // they are available before a Program exists — which is the point, and matters
77
+ // more here than for hints: the artifacts a rule materializes come from parsing
78
+ // documents, not from the type system, so a project that publishes them should
79
+ // not pay for a checker no projection reads.
80
+ func (e *Engine) hasGraphPublisher() (publishes bool, needsChecker bool) {
81
+ if e == nil {
82
+ return false, false
83
+ }
84
+ for _, name := range allProjectRuleNames() {
85
+ setting := e.projectSettings[name]
86
+ if !setting.Declared || setting.Severity == SeverityOff {
87
+ continue
88
+ }
89
+ adapter, registered := registeredProjectRules[name]
90
+ if !registered {
91
+ continue
92
+ }
93
+ if _, publisher := adapter.inner.(publicrule.GraphRule); !publisher {
94
+ continue
95
+ }
96
+ publishes = true
97
+ if projectRuleNeedsTypeChecker(name) {
98
+ // Every publisher's need is folded in rather than returning early: one
99
+ // rule declining a checker does not spare a sibling that reads one.
100
+ needsChecker = true
101
+ }
102
+ }
103
+ return publishes, needsChecker
104
+ }
105
+
106
+ // collectProjectGraphNodes gathers the artifacts every declared project rule
107
+ // published for this Program.
108
+ //
109
+ // It runs after evaluateProject rather than inside it, because a set of
110
+ // artifacts is a projection of finished state. A rule is asked only when it
111
+ // passed and published state: a rule that failed materialized a description of
112
+ // a Program it just rejected, and indexing that would answer questions from
113
+ // facts the rule itself disowns.
114
+ func collectProjectGraphNodes(cycle *projectCycle) []publicrule.GraphNode {
115
+ if cycle == nil || cycle.results == nil {
116
+ return nil
117
+ }
118
+ nodes := []publicrule.GraphNode{}
119
+ for _, name := range allProjectRuleNames() {
120
+ result, exists := cycle.results.byName[name]
121
+ if !exists || result.reporter == nil {
122
+ continue
123
+ }
124
+ adapter, registered := registeredProjectRules[name]
125
+ if !registered {
126
+ continue
127
+ }
128
+ provider, ok := adapter.inner.(publicrule.GraphRule)
129
+ if !ok {
130
+ continue
131
+ }
132
+ snapshot := result.reporter.snapshot()
133
+ if snapshot.Status != publicrule.ProjectRulePassed || snapshot.State == nil {
134
+ continue
135
+ }
136
+ nodes = append(nodes, ruleGraphNodes(name, provider, result, snapshot)...)
137
+ }
138
+ return dropUnusableGraphNodes(nodes)
139
+ }
140
+
141
+ // ruleGraphNodes calls one rule's GraphNodes behind a recover barrier.
142
+ //
143
+ // The barrier matches the metadata-inspection contract: a contributor panicking
144
+ // while describing itself loses its contribution rather than the process.
145
+ func ruleGraphNodes(
146
+ name string,
147
+ provider publicrule.GraphRule,
148
+ result projectCycleResult,
149
+ snapshot publicrule.ProjectRuleResult,
150
+ ) (nodes []publicrule.GraphNode) {
151
+ defer func() {
152
+ if recovered := recover(); recovered != nil {
153
+ fmt.Fprintf(
154
+ os.Stderr,
155
+ "@ttsc/lint: project rule %q panicked while publishing graph nodes: %v; dropping its artifacts\n",
156
+ name,
157
+ recovered,
158
+ )
159
+ nodes = nil
160
+ }
161
+ }()
162
+ return provider.GraphNodes(&publicrule.GraphContext{
163
+ Identity: result.identity,
164
+ State: snapshot.State,
165
+ Severity: publicrule.Severity(result.severity),
166
+ Options: result.options,
167
+ })
168
+ }
169
+
170
+ // dropUnusableGraphNodes removes what a consumer cannot index, and repairs the
171
+ // one thing it can: a parent naming no published node.
172
+ //
173
+ // A node with no address has no identity to be cited by. A node whose kind is
174
+ // not in the published vocabulary is one the consumer cannot rank or contain,
175
+ // and guessing would be worse than dropping. A duplicate address is kept once,
176
+ // first writer winning, because two nodes under one address make a citation
177
+ // ambiguous — the rule's own aliases are how one artifact answers to two names.
178
+ //
179
+ // A parent that survives none of that is cleared rather than dropping its child:
180
+ // the child is still a real artifact and still citable, it simply sits at the
181
+ // top of its chain. Fabricating the missing parent is what must not happen.
182
+ func dropUnusableGraphNodes(nodes []publicrule.GraphNode) []publicrule.GraphNode {
183
+ kinds := publicrule.GraphNodeKinds()
184
+ seen := make(map[string]struct{}, len(nodes))
185
+ kept := make([]publicrule.GraphNode, 0, len(nodes))
186
+ for _, node := range nodes {
187
+ if node.Address == "" || !slices.Contains(kinds, node.Kind) {
188
+ continue
189
+ }
190
+ if _, exists := seen[node.Address]; exists {
191
+ continue
192
+ }
193
+ seen[node.Address] = struct{}{}
194
+ kept = append(kept, node)
195
+ }
196
+ for index := range kept {
197
+ if kept[index].Parent == "" {
198
+ continue
199
+ }
200
+ if _, exists := seen[kept[index].Parent]; !exists {
201
+ kept[index].Parent = ""
202
+ }
203
+ }
204
+ return kept
205
+ }
package/linthost/hints.go CHANGED
@@ -36,7 +36,7 @@ func RunLSPHints(args []string) int {
36
36
  // the resident lsp-serve loop reuses it over a warm Program, the same split
37
37
  // computeLSPDiagnostics and computeLSPCodeActions already take.
38
38
  func computeLSPHints(opts *lspCommandOptions) ([]publicrule.Hint, int) {
39
- rules, err := loadRules(opts.pluginsJSON, opts.cwd, opts.tsconfig)
39
+ rules, err := acquireRules(opts.pluginsJSON, opts.cwd, opts.tsconfig)
40
40
  if err != nil {
41
41
  fmt.Fprintln(os.Stderr, err)
42
42
  return nil, 2
@@ -30,10 +30,21 @@ func RunProjectInputs(args []string) int {
30
30
  if !ok {
31
31
  return 2
32
32
  }
33
- resolver, err := loadRules(opts.pluginsJSON, opts.cwd, opts.tsconfig)
33
+ snapshot, code := computeProjectInputs(opts)
34
+ if code != 0 {
35
+ return code
36
+ }
37
+ return writeJSON(snapshot)
38
+ }
39
+
40
+ // computeProjectInputs builds the dependency snapshot for one project. Split
41
+ // from RunProjectInputs so the resident daemon can answer the same verb without
42
+ // a process per question, the same split hints and graph-nodes take.
43
+ func computeProjectInputs(opts *lspCommandOptions) (ProjectInputSnapshot, int) {
44
+ resolver, err := acquireRules(opts.pluginsJSON, opts.cwd, opts.tsconfig)
34
45
  if err != nil {
35
46
  fmt.Fprintln(os.Stderr, err)
36
- return 2
47
+ return ProjectInputSnapshot{}, 2
37
48
  }
38
49
  identity := normalizeProjectIdentity(
39
50
  opts.projectIdentity,
@@ -43,9 +54,9 @@ func RunProjectInputs(args []string) int {
43
54
  snapshot, err := collectProjectInputs(resolver, identity)
44
55
  if err != nil {
45
56
  fmt.Fprintln(os.Stderr, err)
46
- return 2
57
+ return ProjectInputSnapshot{}, 2
47
58
  }
48
- return writeJSON(snapshot)
59
+ return snapshot, 0
49
60
  }
50
61
 
51
62
  func collectProjectInputs(