@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.
- package/lib/index.d.ts +5 -1
- package/lib/index.js +458 -112
- package/lib/index.js.map +1 -1
- package/linthost/compile.go +147 -3
- package/linthost/config.go +230 -25
- package/linthost/dispatch.go +3 -1
- package/linthost/graph_nodes.go +205 -0
- package/linthost/hints.go +1 -1
- package/linthost/project_inputs.go +15 -4
- package/linthost/serve.go +19 -0
- package/package.json +2 -2
- package/rule/graph.go +150 -0
- package/src/index.ts +511 -115
package/linthost/config.go
CHANGED
|
@@ -1294,10 +1294,12 @@ func loadConfigFile(location string) (any, error) {
|
|
|
1294
1294
|
}
|
|
1295
1295
|
|
|
1296
1296
|
type configDependencyFingerprint struct {
|
|
1297
|
-
Path
|
|
1298
|
-
Digest
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
-
//
|
|
1371
|
-
//
|
|
1372
|
-
//
|
|
1373
|
-
const configCacheVersion = "
|
|
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
|
-
|
|
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
|
|
1862
|
-
|
|
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:
|
|
1876
|
-
Digest:
|
|
1877
|
-
|
|
1878
|
-
|
|
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
|
-
"-
|
|
1923
|
-
|
|
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((
|
|
2879
|
-
|
|
2880
|
-
|
|
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:
|
|
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((
|
|
4140
|
-
|
|
4141
|
-
|
|
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,
|
package/linthost/dispatch.go
CHANGED
|
@@ -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 :=
|
|
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
|
-
|
|
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
|
|
59
|
+
return snapshot, 0
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
func collectProjectInputs(
|