@ttsc/lint 0.26.2 → 0.27.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 +4 -1
- package/lib/index.js +457 -112
- package/lib/index.js.map +1 -1
- package/linthost/compile.go +19 -3
- package/linthost/config.go +230 -25
- package/package.json +2 -2
- package/src/index.ts +509 -115
package/lib/index.js
CHANGED
|
@@ -48,20 +48,20 @@ function goSubpackageName(namespace) {
|
|
|
48
48
|
return namespace.replace(/-/g, "_");
|
|
49
49
|
}
|
|
50
50
|
const LINT_CONFIG_FILENAMES = [
|
|
51
|
+
"lint.config.json",
|
|
52
|
+
"lint.config.js",
|
|
53
|
+
"lint.config.mjs",
|
|
54
|
+
"lint.config.cjs",
|
|
51
55
|
"lint.config.ts",
|
|
52
56
|
"lint.config.mts",
|
|
53
57
|
"lint.config.cts",
|
|
54
|
-
"lint.config.
|
|
55
|
-
"lint.config.
|
|
56
|
-
"lint.config.
|
|
57
|
-
"lint.config.
|
|
58
|
+
"ttsc-lint.config.json",
|
|
59
|
+
"ttsc-lint.config.js",
|
|
60
|
+
"ttsc-lint.config.mjs",
|
|
61
|
+
"ttsc-lint.config.cjs",
|
|
58
62
|
"ttsc-lint.config.ts",
|
|
59
63
|
"ttsc-lint.config.mts",
|
|
60
64
|
"ttsc-lint.config.cts",
|
|
61
|
-
"ttsc-lint.config.mjs",
|
|
62
|
-
"ttsc-lint.config.cjs",
|
|
63
|
-
"ttsc-lint.config.js",
|
|
64
|
-
"ttsc-lint.config.json",
|
|
65
65
|
];
|
|
66
66
|
/**
|
|
67
67
|
* Tsconfig plugin-entry keys owned by the ttsc host framework. They are
|
|
@@ -95,7 +95,7 @@ const FRAMEWORK_KEYS = new Set([
|
|
|
95
95
|
*/
|
|
96
96
|
function createTtscPlugin(context) {
|
|
97
97
|
rejectUnsupportedEntryKeys(context.plugin);
|
|
98
|
-
const
|
|
98
|
+
const resolvedConfig = resolveConfigFileContributors(context);
|
|
99
99
|
// Build the descriptor without a `contributors` key when none were
|
|
100
100
|
// declared, so consumers (and the existing key-shape regression
|
|
101
101
|
// tests) see the same surface as before this feature shipped.
|
|
@@ -109,6 +109,9 @@ function createTtscPlugin(context) {
|
|
|
109
109
|
residentCheck: true,
|
|
110
110
|
threadingArgs: true,
|
|
111
111
|
},
|
|
112
|
+
hostInputHashes: resolvedConfig.hostInputHashes,
|
|
113
|
+
hostInputRealpaths: resolvedConfig.hostInputRealpaths,
|
|
114
|
+
hostInputs: resolvedConfig.hostInputs,
|
|
112
115
|
name: "@ttsc/lint",
|
|
113
116
|
reportsTypeScriptDiagnostics: true,
|
|
114
117
|
// `context.dirname` is this descriptor's own directory in every load mode —
|
|
@@ -117,8 +120,8 @@ function createTtscPlugin(context) {
|
|
|
117
120
|
source: node_path_1.default.resolve(context.dirname, "..", "plugin"),
|
|
118
121
|
stage: "check",
|
|
119
122
|
};
|
|
120
|
-
if (contributors.length > 0) {
|
|
121
|
-
descriptor.contributors = contributors;
|
|
123
|
+
if (resolvedConfig.contributors.length > 0) {
|
|
124
|
+
descriptor.contributors = resolvedConfig.contributors;
|
|
122
125
|
}
|
|
123
126
|
return descriptor;
|
|
124
127
|
}
|
|
@@ -163,12 +166,28 @@ function loadContributorPluginViaRequire(specifier, context, namespace, anchorFi
|
|
|
163
166
|
*/
|
|
164
167
|
function resolveConfigFileContributors(context) {
|
|
165
168
|
const configFile = readConfigFileOption(context);
|
|
166
|
-
const
|
|
167
|
-
?
|
|
168
|
-
:
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
const explicitConfigPath = configFile === undefined
|
|
170
|
+
? undefined
|
|
171
|
+
: node_path_1.default.resolve(pluginConfigBaseDir(context), configFile);
|
|
172
|
+
const discovery = explicitConfigPath === undefined
|
|
173
|
+
? discoverLintConfigFile(context)
|
|
174
|
+
: {
|
|
175
|
+
configPath: explicitConfigPath,
|
|
176
|
+
hostInputHashes: hashHostInputPaths([explicitConfigPath]),
|
|
177
|
+
hostInputRealpaths: realpathHostInputPaths([explicitConfigPath]),
|
|
178
|
+
hostInputs: [explicitConfigPath],
|
|
179
|
+
};
|
|
180
|
+
const { configPath } = discovery;
|
|
181
|
+
if (!configPath || !node_fs_1.default.existsSync(configPath)) {
|
|
182
|
+
return {
|
|
183
|
+
contributors: [],
|
|
184
|
+
hostInputHashes: discovery.hostInputHashes,
|
|
185
|
+
hostInputRealpaths: discovery.hostInputRealpaths,
|
|
186
|
+
hostInputs: discovery.hostInputs,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const evaluation = readConfigPluginEntries(configPath, context);
|
|
190
|
+
const entries = evaluation.entries;
|
|
172
191
|
assertContributorNamespacesDoNotCollide(entries, configPath);
|
|
173
192
|
// Dedup exact repeated namespaces on the Go-subpackage form. Config-array
|
|
174
193
|
// folding can surface the same namespace more than once; that existing
|
|
@@ -182,7 +201,157 @@ function resolveConfigFileContributors(context) {
|
|
|
182
201
|
occupied.add(goName);
|
|
183
202
|
out.push({ name: goName, source: entry.source });
|
|
184
203
|
}
|
|
185
|
-
|
|
204
|
+
const dependencyInputs = evaluation.dependencies
|
|
205
|
+
.filter((dependency) => dependency.scope === "watch" && dependency.kind !== "directory")
|
|
206
|
+
.map((dependency) => dependency.path);
|
|
207
|
+
const hostInputHashes = { ...discovery.hostInputHashes };
|
|
208
|
+
const hostInputRealpaths = { ...discovery.hostInputRealpaths };
|
|
209
|
+
const unstableRealpaths = new Set();
|
|
210
|
+
const unprovenInputs = new Set();
|
|
211
|
+
const missingOptionalDigest = (0, node_crypto_1.createHash)("sha256")
|
|
212
|
+
.update("missing\0")
|
|
213
|
+
.digest("hex");
|
|
214
|
+
const directoryCandidateDigest = (0, node_crypto_1.createHash)("sha256")
|
|
215
|
+
.update("ttsc:host-input:directory\0")
|
|
216
|
+
.digest("hex");
|
|
217
|
+
for (const dependency of evaluation.dependencies) {
|
|
218
|
+
if (dependency.scope !== "watch" || dependency.kind === "directory") {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const input = node_path_1.default.resolve(dependency.path);
|
|
222
|
+
const realpath = dependency.realpath;
|
|
223
|
+
if (!dependency.identityStable) {
|
|
224
|
+
delete hostInputRealpaths[input];
|
|
225
|
+
delete hostInputHashes[input];
|
|
226
|
+
unstableRealpaths.add(input);
|
|
227
|
+
unprovenInputs.add(input);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (Object.prototype.hasOwnProperty.call(hostInputRealpaths, input) &&
|
|
231
|
+
hostInputRealpaths[input] !== realpath) {
|
|
232
|
+
delete hostInputRealpaths[input];
|
|
233
|
+
delete hostInputHashes[input];
|
|
234
|
+
unstableRealpaths.add(input);
|
|
235
|
+
}
|
|
236
|
+
else if (!unstableRealpaths.has(input)) {
|
|
237
|
+
hostInputRealpaths[input] = realpath;
|
|
238
|
+
}
|
|
239
|
+
let hash;
|
|
240
|
+
if (dependency.kind === "file" &&
|
|
241
|
+
/^[0-9a-f]{64}$/.test(dependency.digest)) {
|
|
242
|
+
hash = dependency.digest;
|
|
243
|
+
}
|
|
244
|
+
else if (dependency.digest === missingOptionalDigest) {
|
|
245
|
+
// The evaluator's optional-file digest includes a state prefix. The
|
|
246
|
+
// public host-input contract uses null for the observed missing state.
|
|
247
|
+
hash = null;
|
|
248
|
+
}
|
|
249
|
+
else if (dependency.digest === directoryCandidateDigest) {
|
|
250
|
+
// A path that is currently a directory is still an exact file candidate:
|
|
251
|
+
// replacing it with a file changes module/config selection.
|
|
252
|
+
hash = directoryCandidateDigest;
|
|
253
|
+
}
|
|
254
|
+
if (hash === undefined) {
|
|
255
|
+
delete hostInputHashes[input];
|
|
256
|
+
unprovenInputs.add(input);
|
|
257
|
+
}
|
|
258
|
+
else if (unprovenInputs.has(input)) {
|
|
259
|
+
// A later observation cannot revive proof another evaluation stage
|
|
260
|
+
// could not provide for the same combined descriptor result.
|
|
261
|
+
}
|
|
262
|
+
else if (Object.prototype.hasOwnProperty.call(hostInputHashes, input) &&
|
|
263
|
+
hostInputHashes[input] !== hash) {
|
|
264
|
+
delete hostInputHashes[input];
|
|
265
|
+
unprovenInputs.add(input);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
hostInputHashes[input] = hash;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
contributors: out,
|
|
273
|
+
hostInputHashes,
|
|
274
|
+
hostInputRealpaths,
|
|
275
|
+
hostInputs: [...discovery.hostInputs, ...dependencyInputs],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** Snapshot config candidates before any discovery/evaluation side effect. */
|
|
279
|
+
function hashHostInputPaths(inputs) {
|
|
280
|
+
return Object.fromEntries(inputs.map((input) => {
|
|
281
|
+
const file = node_path_1.default.resolve(input);
|
|
282
|
+
try {
|
|
283
|
+
if (node_fs_1.default.statSync(file).isDirectory()) {
|
|
284
|
+
return [
|
|
285
|
+
file,
|
|
286
|
+
(0, node_crypto_1.createHash)("sha256")
|
|
287
|
+
.update("ttsc:host-input:directory\0")
|
|
288
|
+
.digest("hex"),
|
|
289
|
+
];
|
|
290
|
+
}
|
|
291
|
+
return [
|
|
292
|
+
file,
|
|
293
|
+
(0, node_crypto_1.createHash)("sha256").update(node_fs_1.default.readFileSync(file)).digest("hex"),
|
|
294
|
+
];
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return [file, null];
|
|
298
|
+
}
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
function hostInputRealpath(file) {
|
|
302
|
+
try {
|
|
303
|
+
return node_fs_1.default.realpathSync.native(file);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function realpathHostInputPaths(inputs) {
|
|
310
|
+
return Object.fromEntries(inputs.map((input) => {
|
|
311
|
+
const file = node_path_1.default.resolve(input);
|
|
312
|
+
return [file, hostInputRealpath(file)];
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
/** Mirror native discovery and fingerprint every candidate before selecting. */
|
|
316
|
+
function discoverLintConfigFile(context) {
|
|
317
|
+
const hostInputHashes = {};
|
|
318
|
+
const hostInputRealpaths = {};
|
|
319
|
+
const hostInputs = [];
|
|
320
|
+
const recordCandidates = (candidates) => {
|
|
321
|
+
for (const candidate of candidates) {
|
|
322
|
+
const absolute = node_path_1.default.resolve(candidate);
|
|
323
|
+
if (Object.prototype.hasOwnProperty.call(hostInputHashes, absolute)) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
hostInputs.push(absolute);
|
|
327
|
+
Object.assign(hostInputHashes, hashHostInputPaths([absolute]));
|
|
328
|
+
Object.assign(hostInputRealpaths, realpathHostInputPaths([absolute]));
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
for (const origin of discoveryConfigBaseDirs(context)) {
|
|
332
|
+
for (let directory = origin;; directory = node_path_1.default.dirname(directory)) {
|
|
333
|
+
const candidates = LINT_CONFIG_FILENAMES.map((name) => node_path_1.default.join(directory, name));
|
|
334
|
+
recordCandidates(candidates);
|
|
335
|
+
const matches = lintConfigMatchesIn(directory);
|
|
336
|
+
if (matches.length === 1) {
|
|
337
|
+
return {
|
|
338
|
+
configPath: matches[0],
|
|
339
|
+
hostInputHashes,
|
|
340
|
+
hostInputRealpaths,
|
|
341
|
+
hostInputs,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (matches.length > 1) {
|
|
345
|
+
throw new Error(`@ttsc/lint: multiple lint config files found in ${directory} (${matches
|
|
346
|
+
.map((file) => node_path_1.default.basename(file))
|
|
347
|
+
.join(", ")}); set "configFile" explicitly`);
|
|
348
|
+
}
|
|
349
|
+
const parent = node_path_1.default.dirname(directory);
|
|
350
|
+
if (parent === directory)
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return { hostInputHashes, hostInputRealpaths, hostInputs };
|
|
186
355
|
}
|
|
187
356
|
function assertContributorNamespacesDoNotCollide(entries, configPath) {
|
|
188
357
|
const namespacesByGoName = new Map();
|
|
@@ -237,23 +406,6 @@ function readConfigFileOption(context) {
|
|
|
237
406
|
}
|
|
238
407
|
return value;
|
|
239
408
|
}
|
|
240
|
-
function findLintConfigFile(context) {
|
|
241
|
-
// Mirror the Go side (driver.PluginConfigBaseDir): the caller-declared
|
|
242
|
-
// pluginConfigDir is the single walk origin when present — it names the
|
|
243
|
-
// real project when the resolved tsconfig is a generated wrapper in a temp
|
|
244
|
-
// dir (@ttsc/unplugin's alias overlay), and it keeps the wrapper's temp
|
|
245
|
-
// ancestry out of the walk so a stray config planted there is never
|
|
246
|
-
// honored. Otherwise walk upward from the tsconfig directory first, then
|
|
247
|
-
// fall back to the working directory: that covers callers that point at an
|
|
248
|
-
// out-of-tree tsconfig without declaring an anchor.
|
|
249
|
-
for (const origin of discoveryConfigBaseDirs(context)) {
|
|
250
|
-
const discovered = findLintConfigFileFrom(origin);
|
|
251
|
-
if (discovered !== undefined) {
|
|
252
|
-
return discovered;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
return undefined;
|
|
256
|
-
}
|
|
257
409
|
function discoveryConfigBaseDirs(context) {
|
|
258
410
|
if (context.pluginConfigDir) {
|
|
259
411
|
return [node_path_1.default.resolve(context.cwd ?? ".", context.pluginConfigDir)];
|
|
@@ -262,45 +414,25 @@ function discoveryConfigBaseDirs(context) {
|
|
|
262
414
|
const cwd = node_path_1.default.resolve(context.cwd ?? context.projectRoot);
|
|
263
415
|
return tsconfigDir === cwd ? [tsconfigDir] : [tsconfigDir, cwd];
|
|
264
416
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
// the binary's own discovery to surface the issue once with one canonical
|
|
271
|
-
// message).
|
|
272
|
-
const candidateSet = new Set(LINT_CONFIG_FILENAMES);
|
|
273
|
-
let dir = origin;
|
|
274
|
-
while (true) {
|
|
275
|
-
// One `readdirSync` per directory level beats 14 `existsSync`+
|
|
276
|
-
// `statSync` pairs (= 28 stat syscalls) per level; intersect the
|
|
277
|
-
// listing with the candidate set instead.
|
|
278
|
-
let entries;
|
|
417
|
+
/** Return the non-directory candidates native discovery recognizes. */
|
|
418
|
+
function lintConfigMatchesIn(directory) {
|
|
419
|
+
const matches = [];
|
|
420
|
+
for (const name of LINT_CONFIG_FILENAMES) {
|
|
421
|
+
const candidate = node_path_1.default.join(directory, name);
|
|
279
422
|
try {
|
|
280
|
-
|
|
423
|
+
// Go's os.Stat follows symlinks and junctions. Follow them here too so a
|
|
424
|
+
// directory or dangling link cannot stop descriptor discovery before
|
|
425
|
+
// the native host reaches a valid ancestor config. Probing the canonical
|
|
426
|
+
// candidate spelling also preserves Go's behavior on case-insensitive
|
|
427
|
+
// filesystems when the directory entry uses different casing.
|
|
428
|
+
if (!node_fs_1.default.statSync(candidate).isDirectory())
|
|
429
|
+
matches.push(candidate);
|
|
281
430
|
}
|
|
282
431
|
catch {
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
const matches = [];
|
|
286
|
-
for (const entry of entries) {
|
|
287
|
-
if (!candidateSet.has(entry.name))
|
|
288
|
-
continue;
|
|
289
|
-
if (!entry.isFile() && !entry.isSymbolicLink())
|
|
290
|
-
continue;
|
|
291
|
-
matches.push(node_path_1.default.join(dir, entry.name));
|
|
292
|
-
}
|
|
293
|
-
if (matches.length === 1) {
|
|
294
|
-
return matches[0];
|
|
432
|
+
// Missing, dangling, and unreadable candidates are not native matches.
|
|
295
433
|
}
|
|
296
|
-
if (matches.length > 1) {
|
|
297
|
-
return undefined; // ambiguous — defer to the Go side's error
|
|
298
|
-
}
|
|
299
|
-
const parent = node_path_1.default.dirname(dir);
|
|
300
|
-
if (parent === dir)
|
|
301
|
-
return undefined;
|
|
302
|
-
dir = parent;
|
|
303
434
|
}
|
|
435
|
+
return matches;
|
|
304
436
|
}
|
|
305
437
|
/**
|
|
306
438
|
* Base directory for resolving a relative `configFile` from the tsconfig plugin
|
|
@@ -332,8 +464,9 @@ function readConfigPluginEntries(configPath, context) {
|
|
|
332
464
|
// is a real subprocess, and a host that only wanted to know whether there
|
|
333
465
|
// were contributors would otherwise depend on a launcher being resolvable and
|
|
334
466
|
// on a compiler accepting one more invocation.
|
|
335
|
-
if (jsonConfigDeclaresNoContributor(configPath))
|
|
336
|
-
return [];
|
|
467
|
+
if (jsonConfigDeclaresNoContributor(configPath)) {
|
|
468
|
+
return { dependencies: [], entries: [] };
|
|
469
|
+
}
|
|
337
470
|
// Every other config uses the same isolated evaluator. Executable config can
|
|
338
471
|
// name contributor packages whose top-level code writes to stdout, so loading
|
|
339
472
|
// it in this host process would corrupt CLI JSON or preface the first LSP
|
|
@@ -417,9 +550,11 @@ const CONFIG_KEYS = new Set<string>([
|
|
|
417
550
|
]);
|
|
418
551
|
const dependencies = new Map<string, {
|
|
419
552
|
digest: string;
|
|
553
|
+
identityStable: boolean;
|
|
420
554
|
kind: "directory" | "file" | "optional-file";
|
|
421
555
|
path: string;
|
|
422
556
|
owners: Set<string>;
|
|
557
|
+
realpath: string | null;
|
|
423
558
|
}>();
|
|
424
559
|
const graphNodes = new Map<string, string>();
|
|
425
560
|
const graphEdges: Array<{
|
|
@@ -450,6 +585,23 @@ const configUrlSpellings = [
|
|
|
450
585
|
pathToFileURL(realConfigLocation()).href,
|
|
451
586
|
]),
|
|
452
587
|
];
|
|
588
|
+
const moduleProbeExtensions = [
|
|
589
|
+
".ts",
|
|
590
|
+
".tsx",
|
|
591
|
+
".mts",
|
|
592
|
+
".cts",
|
|
593
|
+
".js",
|
|
594
|
+
".mjs",
|
|
595
|
+
".cjs",
|
|
596
|
+
".json",
|
|
597
|
+
".node",
|
|
598
|
+
] as const;
|
|
599
|
+
const jsToTsProbeExtensions = new Map<string, readonly string[]>([
|
|
600
|
+
[".js", [".ts", ".tsx"]],
|
|
601
|
+
[".jsx", [".tsx"]],
|
|
602
|
+
[".mjs", [".mts"]],
|
|
603
|
+
[".cjs", [".cts"]],
|
|
604
|
+
]);
|
|
453
605
|
for (const spelling of configUrlSpellings) {
|
|
454
606
|
graphNodes.set(spelling, configLocation);
|
|
455
607
|
}
|
|
@@ -471,6 +623,11 @@ declare const process: {
|
|
|
471
623
|
|
|
472
624
|
const hooks = registerHooks({
|
|
473
625
|
resolve(specifier, context, nextResolve) {
|
|
626
|
+
const requestedParent =
|
|
627
|
+
context.parentURL && new URL(context.parentURL).href;
|
|
628
|
+
if (requestedParent !== undefined && graphNodes.has(requestedParent)) {
|
|
629
|
+
recordLocalResolutionCandidates(specifier, requestedParent);
|
|
630
|
+
}
|
|
474
631
|
const resolved = nextResolve(specifier, context);
|
|
475
632
|
if (typeof resolved.url !== "string" || !resolved.url.startsWith("file:")) {
|
|
476
633
|
return resolved;
|
|
@@ -603,14 +760,32 @@ function recordDependency(
|
|
|
603
760
|
const previous = dependencies.get(key);
|
|
604
761
|
const mergedOwners = previous?.owners ?? new Set<string>();
|
|
605
762
|
for (const owner of owners) mergedOwners.add(owner);
|
|
763
|
+
const realpath = dependencyRealpath(location);
|
|
764
|
+
const identityStable =
|
|
765
|
+
previous?.identityStable !== false &&
|
|
766
|
+
(previous === undefined || previous.realpath === realpath);
|
|
606
767
|
dependencies.set(key, {
|
|
607
|
-
digest:
|
|
768
|
+
digest:
|
|
769
|
+
!identityStable ||
|
|
770
|
+
(previous !== undefined && previous.digest !== digest)
|
|
771
|
+
? ""
|
|
772
|
+
: digest,
|
|
773
|
+
identityStable,
|
|
608
774
|
kind,
|
|
609
775
|
owners: mergedOwners,
|
|
610
776
|
path: location,
|
|
777
|
+
realpath,
|
|
611
778
|
});
|
|
612
779
|
}
|
|
613
780
|
|
|
781
|
+
function dependencyRealpath(location: string): string | null {
|
|
782
|
+
try {
|
|
783
|
+
return realPath(location);
|
|
784
|
+
} catch {
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
614
789
|
function isLocalModuleSpecifier(specifier: string): boolean {
|
|
615
790
|
return specifier.startsWith(".") ||
|
|
616
791
|
specifier.startsWith("/") ||
|
|
@@ -728,13 +903,19 @@ function directoryDigestRecord(
|
|
|
728
903
|
|
|
729
904
|
function optionalFileDigest(location: string): string {
|
|
730
905
|
try {
|
|
731
|
-
|
|
906
|
+
const entry = fs.statSync(location);
|
|
907
|
+
if (entry.isFile()) {
|
|
732
908
|
return createHash("sha256")
|
|
733
909
|
.update(Buffer.concat([Buffer.from("file\\0"), fs.readFileSync(location)]))
|
|
734
910
|
.digest("hex");
|
|
735
911
|
}
|
|
912
|
+
if (entry.isDirectory()) {
|
|
913
|
+
return createHash("sha256")
|
|
914
|
+
.update("ttsc:host-input:directory\\0")
|
|
915
|
+
.digest("hex");
|
|
916
|
+
}
|
|
736
917
|
} catch {
|
|
737
|
-
// Missing
|
|
918
|
+
// Missing and unreadable candidates share the absent state.
|
|
738
919
|
}
|
|
739
920
|
return createHash("sha256").update("missing\\0").digest("hex");
|
|
740
921
|
}
|
|
@@ -760,6 +941,80 @@ function recordOptionalFileDependency(
|
|
|
760
941
|
return false;
|
|
761
942
|
}
|
|
762
943
|
|
|
944
|
+
function moduleResolutionCandidates(base: string): string[] {
|
|
945
|
+
const extension = path.extname(base).toLowerCase();
|
|
946
|
+
const substitutions = jsToTsProbeExtensions.get(extension) ?? [];
|
|
947
|
+
const stem = base.slice(0, base.length - extension.length);
|
|
948
|
+
return [
|
|
949
|
+
base,
|
|
950
|
+
...substitutions.map((candidate) => stem + candidate),
|
|
951
|
+
...moduleProbeExtensions.map((candidate) => base + candidate),
|
|
952
|
+
path.join(base, "package.json"),
|
|
953
|
+
...moduleProbeExtensions.map((candidate) =>
|
|
954
|
+
path.join(base, "index" + candidate),
|
|
955
|
+
),
|
|
956
|
+
];
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Record exact local probes before the runtime resolver chooses one. */
|
|
960
|
+
function recordLocalResolutionCandidates(
|
|
961
|
+
specifier: string,
|
|
962
|
+
parentUrl: string,
|
|
963
|
+
): void {
|
|
964
|
+
if (!isLocalModuleSpecifier(specifier)) return;
|
|
965
|
+
let bases: string[];
|
|
966
|
+
try {
|
|
967
|
+
if (specifier.startsWith("file:")) {
|
|
968
|
+
bases = [fileURLToPath(specifier)];
|
|
969
|
+
} else {
|
|
970
|
+
const directory = path.dirname(fileURLToPath(parentUrl));
|
|
971
|
+
const raw = path.resolve(directory, specifier);
|
|
972
|
+
const suffixStart = specifier.search(/[?#]/);
|
|
973
|
+
const pathname =
|
|
974
|
+
suffixStart === -1 ? specifier : specifier.slice(0, suffixStart);
|
|
975
|
+
bases = pathname === ""
|
|
976
|
+
? [raw]
|
|
977
|
+
: [...new Set([raw, path.resolve(directory, pathname)])];
|
|
978
|
+
}
|
|
979
|
+
} catch {
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
const owners = [parentUrl];
|
|
983
|
+
for (const base of bases) {
|
|
984
|
+
try {
|
|
985
|
+
if (fs.statSync(base).isFile()) {
|
|
986
|
+
recordOptionalFileDependency(base, owners);
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
} catch {
|
|
990
|
+
// A missing exact spelling falls through to source/extension/directory
|
|
991
|
+
// probes, all of which can redirect a later evaluation.
|
|
992
|
+
}
|
|
993
|
+
for (const candidate of moduleResolutionCandidates(base)) {
|
|
994
|
+
recordOptionalFileDependency(candidate, owners);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** CommonJS LOAD_AS_FILE / LOAD_AS_DIRECTORY candidates for one legacy path. */
|
|
1000
|
+
function recordLegacyPackagePathCandidates(
|
|
1001
|
+
candidate: string,
|
|
1002
|
+
owners: readonly string[],
|
|
1003
|
+
): void {
|
|
1004
|
+
for (const file of [
|
|
1005
|
+
candidate,
|
|
1006
|
+
candidate + ".js",
|
|
1007
|
+
candidate + ".json",
|
|
1008
|
+
candidate + ".node",
|
|
1009
|
+
path.join(candidate, "package.json"),
|
|
1010
|
+
path.join(candidate, "index.js"),
|
|
1011
|
+
path.join(candidate, "index.json"),
|
|
1012
|
+
path.join(candidate, "index.node"),
|
|
1013
|
+
]) {
|
|
1014
|
+
recordOptionalFileDependency(file, owners);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
763
1018
|
function recordPackageManifests(
|
|
764
1019
|
location: string,
|
|
765
1020
|
owners: readonly string[],
|
|
@@ -805,26 +1060,26 @@ function recordNodeModulesSearchDirectories(
|
|
|
805
1060
|
// The directory digest of node_modules records a missing scope.
|
|
806
1061
|
}
|
|
807
1062
|
}
|
|
808
|
-
if (packageName !== undefined) {
|
|
809
|
-
const selected = recordPackageCandidateTopology(
|
|
810
|
-
modules,
|
|
811
|
-
packageName,
|
|
812
|
-
specifier,
|
|
813
|
-
childLocation,
|
|
814
|
-
owners,
|
|
815
|
-
conditions,
|
|
816
|
-
);
|
|
817
|
-
if (
|
|
818
|
-
selected ||
|
|
819
|
-
resolvedPackageContains(modules, packageName, childLocation)
|
|
820
|
-
) {
|
|
821
|
-
return;
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
1063
|
}
|
|
825
1064
|
} catch {
|
|
826
1065
|
// Missing search levels do not participate in the current resolution.
|
|
827
1066
|
}
|
|
1067
|
+
if (packageName !== undefined) {
|
|
1068
|
+
const selected = recordPackageCandidateTopology(
|
|
1069
|
+
modules,
|
|
1070
|
+
packageName,
|
|
1071
|
+
specifier,
|
|
1072
|
+
childLocation,
|
|
1073
|
+
owners,
|
|
1074
|
+
conditions,
|
|
1075
|
+
);
|
|
1076
|
+
if (
|
|
1077
|
+
selected ||
|
|
1078
|
+
resolvedPackageContains(modules, packageName, childLocation)
|
|
1079
|
+
) {
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
828
1083
|
if (
|
|
829
1084
|
packageName === undefined &&
|
|
830
1085
|
samePhysicalPath(current, resolutionRoot)
|
|
@@ -846,14 +1101,32 @@ function recordPackageCandidateTopology(
|
|
|
846
1101
|
conditions: readonly string[],
|
|
847
1102
|
): boolean {
|
|
848
1103
|
const packageRoot = path.join(modules, packageName);
|
|
1104
|
+
const subpath = specifier
|
|
1105
|
+
.slice(packageName.length)
|
|
1106
|
+
.replace(/^[/\\\\]+/, "");
|
|
849
1107
|
try {
|
|
850
|
-
if (!fs.statSync(packageRoot).isDirectory())
|
|
1108
|
+
if (!fs.statSync(packageRoot).isDirectory()) {
|
|
1109
|
+
recordOptionalFileDependency(
|
|
1110
|
+
path.join(packageRoot, "package.json"),
|
|
1111
|
+
owners,
|
|
1112
|
+
);
|
|
1113
|
+
recordLegacyPackagePathCandidates(
|
|
1114
|
+
subpath === "" ? packageRoot : path.join(packageRoot, subpath),
|
|
1115
|
+
owners,
|
|
1116
|
+
);
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
851
1119
|
} catch {
|
|
1120
|
+
recordOptionalFileDependency(
|
|
1121
|
+
path.join(packageRoot, "package.json"),
|
|
1122
|
+
owners,
|
|
1123
|
+
);
|
|
1124
|
+
recordLegacyPackagePathCandidates(
|
|
1125
|
+
subpath === "" ? packageRoot : path.join(packageRoot, subpath),
|
|
1126
|
+
owners,
|
|
1127
|
+
);
|
|
852
1128
|
return false;
|
|
853
1129
|
}
|
|
854
|
-
const subpath = specifier
|
|
855
|
-
.slice(packageName.length)
|
|
856
|
-
.replace(/^[/\\\\]+/, "");
|
|
857
1130
|
const rootTopology = recordPackageRootTopology(
|
|
858
1131
|
packageRoot,
|
|
859
1132
|
owners,
|
|
@@ -888,6 +1161,7 @@ function recordPackageRootTopology(
|
|
|
888
1161
|
const legacySelected = (): boolean =>
|
|
889
1162
|
useMain &&
|
|
890
1163
|
packagePathCandidateMatchesChild(normalizedRoot, childLocation, true);
|
|
1164
|
+
if (useMain) recordLegacyPackagePathCandidates(normalizedRoot, owners);
|
|
891
1165
|
if (!recordOptionalFileDependency(manifest, owners)) {
|
|
892
1166
|
const selected = legacySelected();
|
|
893
1167
|
if (!selected) {
|
|
@@ -934,6 +1208,7 @@ function recordPackageRootTopology(
|
|
|
934
1208
|
// it literally and permits absolute paths and paths outside the package.
|
|
935
1209
|
const main = path.resolve(normalizedRoot, metadata.main);
|
|
936
1210
|
recordPackagePathCandidate(main, owners);
|
|
1211
|
+
recordLegacyPackagePathCandidates(main, owners);
|
|
937
1212
|
selected =
|
|
938
1213
|
packagePathCandidateMatchesChild(main, childLocation, true) ||
|
|
939
1214
|
selected;
|
|
@@ -1181,6 +1456,7 @@ function recordPackageSubpathTopology(
|
|
|
1181
1456
|
const candidate = boundedPackageTarget(packageRoot, subpath);
|
|
1182
1457
|
if (candidate === undefined) return false;
|
|
1183
1458
|
recordPackagePathCandidate(candidate, owners);
|
|
1459
|
+
recordLegacyPackagePathCandidates(candidate, owners);
|
|
1184
1460
|
let selected = packagePathCandidateMatchesChild(
|
|
1185
1461
|
candidate,
|
|
1186
1462
|
childLocation,
|
|
@@ -1200,6 +1476,7 @@ function recordPackageSubpathTopology(
|
|
|
1200
1476
|
if (typeof metadata.main === "string") {
|
|
1201
1477
|
const main = path.resolve(candidate, metadata.main);
|
|
1202
1478
|
recordPackagePathCandidate(main, owners);
|
|
1479
|
+
recordLegacyPackagePathCandidates(main, owners);
|
|
1203
1480
|
selected =
|
|
1204
1481
|
packagePathCandidateMatchesChild(main, childLocation, true) ||
|
|
1205
1482
|
selected;
|
|
@@ -1370,10 +1647,25 @@ function realPath(location: string): string {
|
|
|
1370
1647
|
|
|
1371
1648
|
function finalizeDependencies(): Array<{
|
|
1372
1649
|
digest: string;
|
|
1650
|
+
identityStable: boolean;
|
|
1373
1651
|
kind: "directory" | "file" | "optional-file";
|
|
1374
1652
|
path: string;
|
|
1653
|
+
realpath: string | null;
|
|
1375
1654
|
scope: "cache" | "watch";
|
|
1376
1655
|
}> {
|
|
1656
|
+
// The evaluator may have observed a dependency, run arbitrary config code,
|
|
1657
|
+
// and then serialize after that path changed again. Re-read every recorded
|
|
1658
|
+
// dependency under its original ownership set so an A -> B -> A transition
|
|
1659
|
+
// is marked identity-unstable instead of pairing transient output with the
|
|
1660
|
+
// restored fingerprint.
|
|
1661
|
+
for (const dependency of [...dependencies.values()]) {
|
|
1662
|
+
recordDependency(
|
|
1663
|
+
dependency.kind,
|
|
1664
|
+
dependency.path,
|
|
1665
|
+
currentDependencyDigest(dependency.kind, dependency.path),
|
|
1666
|
+
[...dependency.owners],
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1377
1669
|
const watched = graphWatchReachability();
|
|
1378
1670
|
return [...dependencies.values()].map(({ owners, ...dependency }) => ({
|
|
1379
1671
|
...dependency,
|
|
@@ -1383,6 +1675,21 @@ function finalizeDependencies(): Array<{
|
|
|
1383
1675
|
}));
|
|
1384
1676
|
}
|
|
1385
1677
|
|
|
1678
|
+
function currentDependencyDigest(
|
|
1679
|
+
kind: "directory" | "file" | "optional-file",
|
|
1680
|
+
location: string,
|
|
1681
|
+
): string {
|
|
1682
|
+
try {
|
|
1683
|
+
if (kind === "directory") return directoryDigest(location);
|
|
1684
|
+
if (kind === "optional-file") return optionalFileDigest(location);
|
|
1685
|
+
return createHash("sha256")
|
|
1686
|
+
.update(fs.readFileSync(location))
|
|
1687
|
+
.digest("hex");
|
|
1688
|
+
} catch {
|
|
1689
|
+
return "";
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1386
1693
|
function graphWatchReachability(): Set<string> {
|
|
1387
1694
|
const adjacency = new Map<string, typeof graphEdges>();
|
|
1388
1695
|
for (const edge of graphEdges) {
|
|
@@ -1547,7 +1854,7 @@ function readTtsxConfigPlugins(configPath, context) {
|
|
|
1547
1854
|
if (cached &&
|
|
1548
1855
|
cached.entries.every(isValidConfigPluginEntry) &&
|
|
1549
1856
|
configDependenciesAreCurrent(cached.dependencies)) {
|
|
1550
|
-
return cached
|
|
1857
|
+
return cached;
|
|
1551
1858
|
}
|
|
1552
1859
|
}
|
|
1553
1860
|
// A config can be saved while it is being evaluated. Retry a bounded number
|
|
@@ -1560,10 +1867,10 @@ function readTtsxConfigPlugins(configPath, context) {
|
|
|
1560
1867
|
if (configDependenciesAreCurrent(evaluation.dependencies)) {
|
|
1561
1868
|
if (cacheKey)
|
|
1562
1869
|
writeConfigPluginCache(cacheKey, evaluation);
|
|
1563
|
-
return evaluation
|
|
1870
|
+
return evaluation;
|
|
1564
1871
|
}
|
|
1565
1872
|
}
|
|
1566
|
-
return evaluation
|
|
1873
|
+
return evaluation;
|
|
1567
1874
|
}
|
|
1568
1875
|
/**
|
|
1569
1876
|
* Reports whether a cached plugin entry is still usable: a well-formed
|
|
@@ -1590,7 +1897,7 @@ function isValidConfigPluginEntry(entry) {
|
|
|
1590
1897
|
}
|
|
1591
1898
|
}
|
|
1592
1899
|
function evaluateTtsxConfigPlugins(configPath, context) {
|
|
1593
|
-
const tempDir =
|
|
1900
|
+
const tempDir = createCanonicalTempDirectory("ttsc-lint-cfg-", loaderTempBase(configPath));
|
|
1594
1901
|
try {
|
|
1595
1902
|
linkNearestNodeModules(tempDir, node_path_1.default.dirname(configPath));
|
|
1596
1903
|
const loaderPath = node_path_1.default.join(tempDir, "loader.mts");
|
|
@@ -1735,11 +2042,27 @@ function evaluateTtsxConfigPlugins(configPath, context) {
|
|
|
1735
2042
|
// ────────────────────────────────────────────────────────────────────────────
|
|
1736
2043
|
// Config cache (shared with the Go sidecar — packages/lint/linthost/config.go)
|
|
1737
2044
|
// ────────────────────────────────────────────────────────────────────────────
|
|
2045
|
+
/** Create evaluator storage beneath a frozen physical parent. */
|
|
2046
|
+
function createCanonicalTempDirectory(prefix, parent) {
|
|
2047
|
+
const physicalParent = node_fs_1.default.realpathSync.native(parent);
|
|
2048
|
+
if (!node_fs_1.default.lstatSync(physicalParent).isDirectory()) {
|
|
2049
|
+
throw new Error(`@ttsc/lint: temporary directory parent is not a directory: ${physicalParent}`);
|
|
2050
|
+
}
|
|
2051
|
+
const directory = node_fs_1.default.mkdtempSync(node_path_1.default.join(physicalParent, prefix));
|
|
2052
|
+
if (!node_fs_1.default.lstatSync(directory).isDirectory()) {
|
|
2053
|
+
throw new Error(`@ttsc/lint: temporary directory postflight is not a directory: ${directory}`);
|
|
2054
|
+
}
|
|
2055
|
+
const physicalDirectory = node_fs_1.default.realpathSync.native(directory);
|
|
2056
|
+
if (node_path_1.default.dirname(physicalDirectory) !== physicalParent) {
|
|
2057
|
+
throw new Error(`@ttsc/lint: temporary directory escaped its physical parent: ${physicalDirectory}`);
|
|
2058
|
+
}
|
|
2059
|
+
return physicalDirectory;
|
|
2060
|
+
}
|
|
1738
2061
|
/**
|
|
1739
2062
|
* Namespaces the on-disk config cache. Kept in lockstep with the Go sidecar's
|
|
1740
2063
|
* `configCacheVersion`; bump both when the cached shape changes.
|
|
1741
2064
|
*/
|
|
1742
|
-
const CONFIG_CACHE_VERSION = "
|
|
2065
|
+
const CONFIG_CACHE_VERSION = "v7";
|
|
1743
2066
|
/**
|
|
1744
2067
|
* Directory shared by this factory and the Go sidecar for cached lint configs.
|
|
1745
2068
|
* The two write different files (the `kind` segment of the cache key keeps
|
|
@@ -1848,6 +2171,12 @@ function normalizeConfigDependencyFingerprints(value) {
|
|
|
1848
2171
|
typeof candidate !== "object" ||
|
|
1849
2172
|
typeof candidate.path !== "string" ||
|
|
1850
2173
|
typeof candidate.digest !== "string" ||
|
|
2174
|
+
typeof candidate.identityStable !==
|
|
2175
|
+
"boolean" ||
|
|
2176
|
+
(candidate.realpath !== null &&
|
|
2177
|
+
(typeof candidate.realpath !==
|
|
2178
|
+
"string" ||
|
|
2179
|
+
!node_path_1.default.isAbsolute(candidate.realpath))) ||
|
|
1851
2180
|
!["directory", "file", "optional-file"].includes(candidate.kind) ||
|
|
1852
2181
|
!["cache", "watch"].includes(candidate.scope)) {
|
|
1853
2182
|
return undefined;
|
|
@@ -1855,22 +2184,36 @@ function normalizeConfigDependencyFingerprints(value) {
|
|
|
1855
2184
|
const candidatePath = candidate.path;
|
|
1856
2185
|
const digest = candidate.digest;
|
|
1857
2186
|
const kind = candidate.kind;
|
|
2187
|
+
const identityStable = candidate
|
|
2188
|
+
.identityStable;
|
|
2189
|
+
const realpath = candidate.realpath;
|
|
1858
2190
|
const scope = candidate.scope;
|
|
1859
|
-
if (!node_path_1.default.isAbsolute(candidatePath) ||
|
|
2191
|
+
if (!node_path_1.default.isAbsolute(candidatePath) ||
|
|
2192
|
+
(digest !== "" && !/^[0-9a-f]{64}$/.test(digest))) {
|
|
1860
2193
|
return undefined;
|
|
1861
2194
|
}
|
|
1862
2195
|
const location = node_path_1.default.resolve(candidatePath);
|
|
1863
|
-
|
|
2196
|
+
// The same lexical path can be both a traversed directory and an exact
|
|
2197
|
+
// file candidate. Those observations have different freshness contracts:
|
|
2198
|
+
// one fingerprints the listing, while the other fingerprints its entry
|
|
2199
|
+
// type. Preserve both, but still reject contradictory duplicates of one
|
|
2200
|
+
// kind.
|
|
2201
|
+
const key = kind + "\0" + location;
|
|
2202
|
+
const previous = dependencies.get(key);
|
|
1864
2203
|
if (previous !== undefined &&
|
|
1865
2204
|
(previous.digest !== digest ||
|
|
2205
|
+
previous.identityStable !== identityStable ||
|
|
1866
2206
|
previous.kind !== kind ||
|
|
2207
|
+
previous.realpath !== realpath ||
|
|
1867
2208
|
previous.scope !== scope)) {
|
|
1868
2209
|
return undefined;
|
|
1869
2210
|
}
|
|
1870
|
-
dependencies.set(
|
|
2211
|
+
dependencies.set(key, {
|
|
1871
2212
|
digest,
|
|
2213
|
+
identityStable,
|
|
1872
2214
|
kind,
|
|
1873
2215
|
path: location,
|
|
2216
|
+
realpath,
|
|
1874
2217
|
scope,
|
|
1875
2218
|
});
|
|
1876
2219
|
}
|
|
@@ -1880,6 +2223,10 @@ function configDependenciesAreCurrent(dependencies) {
|
|
|
1880
2223
|
if (dependencies.length === 0)
|
|
1881
2224
|
return false;
|
|
1882
2225
|
return dependencies.every((dependency) => {
|
|
2226
|
+
if (!dependency.identityStable ||
|
|
2227
|
+
dependency.realpath !== hostInputRealpath(dependency.path)) {
|
|
2228
|
+
return false;
|
|
2229
|
+
}
|
|
1883
2230
|
if (!/^[0-9a-f]{64}$/.test(dependency.digest))
|
|
1884
2231
|
return false;
|
|
1885
2232
|
try {
|
|
@@ -1950,14 +2297,20 @@ function configDirectoryDigestRecord(name, entry, target) {
|
|
|
1950
2297
|
}
|
|
1951
2298
|
function configOptionalFileDigest(location) {
|
|
1952
2299
|
try {
|
|
1953
|
-
|
|
2300
|
+
const entry = node_fs_1.default.statSync(location);
|
|
2301
|
+
if (entry.isFile()) {
|
|
1954
2302
|
return (0, node_crypto_1.createHash)("sha256")
|
|
1955
2303
|
.update(node_buffer_1.Buffer.concat([node_buffer_1.Buffer.from("file\0"), node_fs_1.default.readFileSync(location)]))
|
|
1956
2304
|
.digest("hex");
|
|
1957
2305
|
}
|
|
2306
|
+
if (entry.isDirectory()) {
|
|
2307
|
+
return (0, node_crypto_1.createHash)("sha256")
|
|
2308
|
+
.update("ttsc:host-input:directory\0")
|
|
2309
|
+
.digest("hex");
|
|
2310
|
+
}
|
|
1958
2311
|
}
|
|
1959
2312
|
catch {
|
|
1960
|
-
// Missing
|
|
2313
|
+
// Missing and unreadable candidates share the absent state.
|
|
1961
2314
|
}
|
|
1962
2315
|
return (0, node_crypto_1.createHash)("sha256").update("missing\0").digest("hex");
|
|
1963
2316
|
}
|
|
@@ -2177,14 +2530,6 @@ function loaderTempBase(configPath) {
|
|
|
2177
2530
|
}
|
|
2178
2531
|
return node_path_1.default.dirname(configPath);
|
|
2179
2532
|
}
|
|
2180
|
-
function realpathIfPossible(location) {
|
|
2181
|
-
try {
|
|
2182
|
-
return node_fs_1.default.realpathSync(location);
|
|
2183
|
-
}
|
|
2184
|
-
catch {
|
|
2185
|
-
return location;
|
|
2186
|
-
}
|
|
2187
|
-
}
|
|
2188
2533
|
function nodeConfigLoaderEnv(configPath) {
|
|
2189
2534
|
const env = { ...process.env };
|
|
2190
2535
|
const parts = [];
|