carrick 0.3.133 → 0.3.134
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/dist/contract.d.ts +6 -0
- package/dist/contract.js.map +1 -1
- package/dist/hook/post-edit.d.ts +10 -5
- package/dist/hook/post-edit.js +80 -23
- package/dist/hook/post-edit.js.map +1 -1
- package/dist/hook/refresh.d.ts +23 -3
- package/dist/hook/refresh.js +48 -6
- package/dist/hook/refresh.js.map +1 -1
- package/dist/hook/reuse.d.ts +7 -21
- package/dist/hook/reuse.js +23 -36
- package/dist/hook/reuse.js.map +1 -1
- package/dist/hook/session-start.js +15 -2
- package/dist/hook/session-start.js.map +1 -1
- package/dist/hook/stop.js.map +1 -1
- package/dist/init/agents-section.js +1 -1
- package/dist/init/agents-section.js.map +1 -1
- package/dist/init/doctor.js +4 -0
- package/dist/init/doctor.js.map +1 -1
- package/dist/init/hosted.d.ts +1 -0
- package/dist/init/hosted.js +4 -1
- package/dist/init/hosted.js.map +1 -1
- package/dist/init/remove.js +7 -1
- package/dist/init/remove.js.map +1 -1
- package/dist/init/repo-copies.js +3 -1
- package/dist/init/repo-copies.js.map +1 -1
- package/dist/init/run.d.ts +1 -1
- package/dist/init/run.js +10 -2
- package/dist/init/run.js.map +1 -1
- package/dist/init/task-skills.d.ts +22 -1
- package/dist/init/task-skills.js +65 -1
- package/dist/init/task-skills.js.map +1 -1
- package/dist/render.d.ts +41 -16
- package/dist/render.js +91 -102
- package/dist/render.js.map +1 -1
- package/package.json +10 -9
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/sidecar/dist/src/capture/anchors.js +18 -1
- package/sidecar/dist/src/capture/api.d.ts +17 -4
- package/sidecar/dist/src/capture/check-workspace.js +1 -1
- package/sidecar/dist/src/capture/check.d.ts +44 -0
- package/sidecar/dist/src/capture/check.js +112 -10
- package/sidecar/dist/src/capture/installed-package.d.ts +5 -0
- package/sidecar/dist/src/capture/installed-package.js +64 -6
- package/sidecar/dist/src/capture/paths-rewrite.js +3 -3
- package/sidecar/dist/src/capture/result-label.d.ts +42 -0
- package/sidecar/dist/src/capture/result-label.js +91 -0
- package/sidecar/dist/src/capture/self-check.js +13 -2
- package/sidecar/dist/src/index.js +116 -4
- package/sidecar/dist/src/infer-cache.d.ts +125 -0
- package/sidecar/dist/src/infer-cache.js +244 -0
- package/sidecar/dist/src/library-claims.js +42 -4
- package/sidecar/dist/src/node-index.d.ts +53 -0
- package/sidecar/dist/src/node-index.js +100 -0
- package/sidecar/dist/src/project-loader.d.ts +11 -0
- package/sidecar/dist/src/project-loader.js +15 -0
- package/sidecar/dist/src/result-candidates.d.ts +28 -0
- package/sidecar/dist/src/result-candidates.js +209 -0
- package/sidecar/dist/src/type-inferrer.d.ts +27 -0
- package/sidecar/dist/src/type-inferrer.js +175 -43
- package/sidecar/dist/src/types.d.ts +52 -0
- package/sidecar/dist/src/unwidened.js +10 -1
- package/sidecar/dist/src/validators.d.ts +919 -197
- package/sidecar/dist/src/validators.js +32 -0
- package/templates/skills/carrick-reuse.md +11 -0
|
@@ -30,6 +30,7 @@ import { readServerLabels } from './server-labels.js';
|
|
|
30
30
|
import { PROGRESS_INTERVAL_MS, atMostEvery } from './progress.js';
|
|
31
31
|
import { mergeInferTimings } from './infer-timing.js';
|
|
32
32
|
import { addProgramFiles, listProgramFiles } from './program-files.js';
|
|
33
|
+
import { changedSource, inferCacheDir, keepable, programDifference, programRecord, rebuildProgram, readEntry, requestDigest, writeEntry, } from './infer-cache.js';
|
|
33
34
|
import { writePositionRows } from './position-rows.js';
|
|
34
35
|
import { findImplementations } from './implementations.js';
|
|
35
36
|
// ===========================================================================
|
|
@@ -37,6 +38,8 @@ import { findImplementations } from './implementations.js';
|
|
|
37
38
|
// ===========================================================================
|
|
38
39
|
let projectLoader = null;
|
|
39
40
|
let initTimeMs = null;
|
|
41
|
+
/** `requestDigest` of the `init` request this process was last scoped with (carrick#2843). */
|
|
42
|
+
let initDigest = '';
|
|
40
43
|
/**
|
|
41
44
|
* Components by project key (`ProjectLoader.projectKeyFor`): `''` is the
|
|
42
45
|
* default project, any other key the program of a project that owns some of
|
|
@@ -160,6 +163,7 @@ function handleInit(request) {
|
|
|
160
163
|
// Re-init re-scopes the sidecar to another root: drop everything built
|
|
161
164
|
// over the previous project before resolving the new one.
|
|
162
165
|
components = new Map();
|
|
166
|
+
initDigest = requestDigest(request);
|
|
163
167
|
projectLoader = new ProjectLoader({
|
|
164
168
|
repoRoot: request.repo_root,
|
|
165
169
|
tsconfigPath: request.tsconfig_path,
|
|
@@ -336,10 +340,19 @@ async function handleCheckV2Async(request) {
|
|
|
336
340
|
function handleInfer(request) {
|
|
337
341
|
try {
|
|
338
342
|
log(`Inferring ${request.requests.length} type(s)`);
|
|
343
|
+
const groups = byProject(request.requests, (item) => item.file_path);
|
|
344
|
+
const cacheKey = inferAnswerKey(request, groups);
|
|
345
|
+
if (cacheKey) {
|
|
346
|
+
const served = servedAnswer(cacheKey);
|
|
347
|
+
if (served) {
|
|
348
|
+
log(`Inferred ${request.requests.length} type(s) from the kept answer (carrick#2843)`);
|
|
349
|
+
return { request_id: request.request_id, ...served };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
339
352
|
// One count for the request, whichever programs its items span.
|
|
340
353
|
let done = 0;
|
|
341
354
|
const report = atMostEvery(PROGRESS_INTERVAL_MS, () => writeProgress(request.request_id, 'infer', `${done} of ${request.requests.length}`));
|
|
342
|
-
const results = [...
|
|
355
|
+
const results = [...groups].map(([key, items]) => {
|
|
343
356
|
const built = projectComponents(key);
|
|
344
357
|
return built.typeInferrer.infer(items, request.extraction_config, () => {
|
|
345
358
|
done += 1;
|
|
@@ -357,14 +370,19 @@ function handleInfer(request) {
|
|
|
357
370
|
errors: errors.length > 0 ? errors : undefined,
|
|
358
371
|
};
|
|
359
372
|
})();
|
|
360
|
-
|
|
361
|
-
request_id: request.request_id,
|
|
373
|
+
const answer = {
|
|
362
374
|
status: result.success ? 'success' : 'error',
|
|
363
375
|
inferred_types: result.inferred_types,
|
|
376
|
+
errors: result.errors,
|
|
377
|
+
};
|
|
378
|
+
if (cacheKey)
|
|
379
|
+
keepAnswer(cacheKey, [...groups.keys()], answer);
|
|
380
|
+
return {
|
|
381
|
+
request_id: request.request_id,
|
|
382
|
+
...answer,
|
|
364
383
|
// Beside the answers and never in them: what the requests cost, for
|
|
365
384
|
// the caller's log (carrick#1985).
|
|
366
385
|
infer_timing: mergeInferTimings(results.map((r) => r.timing)),
|
|
367
|
-
errors: result.errors,
|
|
368
386
|
};
|
|
369
387
|
}
|
|
370
388
|
catch (err) {
|
|
@@ -377,6 +395,100 @@ function handleInfer(request) {
|
|
|
377
395
|
};
|
|
378
396
|
}
|
|
379
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* carrick#2843: what a kept answer to `request` is read against, or
|
|
400
|
+
* `undefined` when no answer to it is kept: the cache is off, the request
|
|
401
|
+
* reads server-framework claims (in a program the record does not cover),
|
|
402
|
+
* the project resolves through a module graph read outside the compiler, or
|
|
403
|
+
* a request before this one built a program it is answered in (its roots
|
|
404
|
+
* then depend on that request).
|
|
405
|
+
*/
|
|
406
|
+
function inferAnswerKey(request, groups) {
|
|
407
|
+
const dir = inferCacheDir();
|
|
408
|
+
if (!dir)
|
|
409
|
+
return undefined;
|
|
410
|
+
const loader = initializedLoader();
|
|
411
|
+
if (loader.resolvesThroughModuleGraph())
|
|
412
|
+
return undefined;
|
|
413
|
+
for (const [key, items] of groups) {
|
|
414
|
+
if (loader.isBuilt(key) || components.has(key))
|
|
415
|
+
return undefined;
|
|
416
|
+
if (request.server_claims &&
|
|
417
|
+
request.server_claims.checks.length > 0 &&
|
|
418
|
+
items.some((item) => ROUTE_ROW_KINDS.has(item.infer_kind))) {
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { dir, repoRoot: loader.getRepoRoot(), init: initDigest, request: requestDigest(request) };
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* carrick#2843: the kept answer for `key` when every program it was read in
|
|
426
|
+
* is, built again now, the same program. Each is built from its tsconfig and
|
|
427
|
+
* given the roots the kept one had, in order, and rebuilt once more when the
|
|
428
|
+
* kept one had been, so a match leaves the program the inference would have
|
|
429
|
+
* left. A program that does not match is dropped
|
|
430
|
+
* again, so the inference that follows builds it as it would have with no
|
|
431
|
+
* cache.
|
|
432
|
+
*/
|
|
433
|
+
function servedAnswer(key) {
|
|
434
|
+
const entry = readEntry(key);
|
|
435
|
+
if (!entry)
|
|
436
|
+
return undefined;
|
|
437
|
+
// A file that changed on disk is found without building a program.
|
|
438
|
+
for (const kept of entry.programs) {
|
|
439
|
+
const changedFile = changedSource(kept);
|
|
440
|
+
if (changedFile) {
|
|
441
|
+
log(`The kept infer answer was read in a program that has changed (${changedFile}): inferring again (carrick#2843)`);
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const loader = initializedLoader();
|
|
446
|
+
const touched = [];
|
|
447
|
+
let same = true;
|
|
448
|
+
let changed = 'a root file it could not read';
|
|
449
|
+
for (const kept of entry.programs) {
|
|
450
|
+
touched.push(kept.key);
|
|
451
|
+
const project = loader.getProjectFor(kept.key);
|
|
452
|
+
addProgramFiles(project, kept.roots, () => {
|
|
453
|
+
same = false;
|
|
454
|
+
});
|
|
455
|
+
if (!same)
|
|
456
|
+
break;
|
|
457
|
+
let now = programRecord(kept.key, project);
|
|
458
|
+
if (now.roots.length < kept.roots.length) {
|
|
459
|
+
// The inference's own rewrites rebuilt the program after its first
|
|
460
|
+
// build, and a rebuild names every file the first one reached as a
|
|
461
|
+
// root. Rebuilt once more here, it names the same.
|
|
462
|
+
rebuildProgram(project);
|
|
463
|
+
now = programRecord(kept.key, project);
|
|
464
|
+
}
|
|
465
|
+
const difference = programDifference(kept, now);
|
|
466
|
+
if (difference) {
|
|
467
|
+
same = false;
|
|
468
|
+
changed = difference;
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (same)
|
|
473
|
+
return entry.answer;
|
|
474
|
+
for (const programKey of touched)
|
|
475
|
+
loader.releaseProjectFor(programKey);
|
|
476
|
+
log(`The kept infer answer was read in a program that has changed (${changed}): inferring again (carrick#2843)`);
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
/** carrick#2843: keep `answer` for `key`, with the programs it was read in as they stand. */
|
|
480
|
+
function keepAnswer(key, programKeys, answer) {
|
|
481
|
+
if (!keepable(answer))
|
|
482
|
+
return;
|
|
483
|
+
try {
|
|
484
|
+
const loader = initializedLoader();
|
|
485
|
+
writeEntry(key, programKeys.map((programKey) => programRecord(programKey, loader.getProjectFor(programKey))), answer);
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
// Keeping an answer never costs the answer.
|
|
489
|
+
logError(`The infer answer was not kept: ${err instanceof Error ? err.message : String(err)}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
380
492
|
/**
|
|
381
493
|
* How long reading one program's server-framework labels may take: as
|
|
382
494
|
* long as a verify_library_claims request, since the reading builds a
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An `infer` answer kept on disk and served again while nothing it was read
|
|
3
|
+
* from has changed (carrick#2843).
|
|
4
|
+
*
|
|
5
|
+
* A scoped re-scan re-analyses a service when one of its imports changed,
|
|
6
|
+
* even when nothing the service's own types are read from did. A generated
|
|
7
|
+
* API client asks hundreds of `infer` requests of a program that a change
|
|
8
|
+
* elsewhere does not touch, and answering them costs tens of seconds each
|
|
9
|
+
* time. This keeps the last answer per service and serves it when the
|
|
10
|
+
* program it was read in is the same program.
|
|
11
|
+
*
|
|
12
|
+
* What keys an answer, all of it compared before one is served:
|
|
13
|
+
* - the sidecar itself: its compiled code, the TypeScript and ts-morph
|
|
14
|
+
* versions it runs and the Node version (`sidecarIdentity`);
|
|
15
|
+
* - the `init` request the process was scoped with, less its id;
|
|
16
|
+
* - the `infer` request, less its id: every item, the extraction config, the
|
|
17
|
+
* model symbols and the server claims;
|
|
18
|
+
* - for each program the request is answered in: its root files in order,
|
|
19
|
+
* its compiler options, and every source file the compiler holds, by path
|
|
20
|
+
* and by the sha256 of its text (`programRecord`).
|
|
21
|
+
*
|
|
22
|
+
* The program is the compiler's whole view: a declaration it could read is a
|
|
23
|
+
* source file in it, and a file that would now resolve in place of another
|
|
24
|
+
* changes the list. To compare it, the lookup builds each program afresh from
|
|
25
|
+
* its tsconfig, adds the roots the stored answer's program had, in order, and
|
|
26
|
+
* compares the result. A program that differs is dropped again, so the
|
|
27
|
+
* inference that follows builds it exactly as it would have with no cache.
|
|
28
|
+
* A program that matches is the one the inference would have left behind, so
|
|
29
|
+
* every later request in the process reads the program it would have read.
|
|
30
|
+
*
|
|
31
|
+
* Not kept: an answer whose unwidened reading ran out of its time budget (it
|
|
32
|
+
* depends on the clock), an answer that read server-framework claims (they
|
|
33
|
+
* are read in a program of their own, which the record does not cover), and
|
|
34
|
+
* an answer of a process whose program a request before it had built (its
|
|
35
|
+
* roots then depend on that request).
|
|
36
|
+
*
|
|
37
|
+
* On only where `CARRICK_INFER_CACHE_DIR` names a directory: local scans set
|
|
38
|
+
* it, and a CI scan, which starts on a clean machine, does not.
|
|
39
|
+
*/
|
|
40
|
+
import type { Project } from 'ts-morph';
|
|
41
|
+
import type { InferredType } from './types.js';
|
|
42
|
+
/** The variable naming the directory answers are kept in. */
|
|
43
|
+
export declare const INFER_CACHE_ENV = "CARRICK_INFER_CACHE_DIR";
|
|
44
|
+
/** One program an answer was read in, as `programRecord` saw it. */
|
|
45
|
+
export interface ProgramRecord {
|
|
46
|
+
key: string;
|
|
47
|
+
/** The program's root files, in order. */
|
|
48
|
+
roots: string[];
|
|
49
|
+
/** Its compiler options, as JSON. */
|
|
50
|
+
options: string;
|
|
51
|
+
/** Every source file the compiler holds: path, module format, sha256 of its text. */
|
|
52
|
+
sources: Array<[string, string, string]>;
|
|
53
|
+
}
|
|
54
|
+
/** The parts of an `infer` answer that are kept and served again. */
|
|
55
|
+
export interface KeptAnswer {
|
|
56
|
+
status: 'success' | 'error';
|
|
57
|
+
inferred_types?: InferredType[];
|
|
58
|
+
errors?: string[];
|
|
59
|
+
}
|
|
60
|
+
/** The directory answers are kept in, or `undefined` when the cache is off. */
|
|
61
|
+
export declare function inferCacheDir(): string | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* The sidecar that reads the answers: every compiled file of its own, the
|
|
64
|
+
* compiler and ts-morph versions it loads, and the Node version.
|
|
65
|
+
*/
|
|
66
|
+
export declare function sidecarIdentity(): string;
|
|
67
|
+
/** A request's digest, less the id every request carries. */
|
|
68
|
+
export declare function requestDigest(request: object): string;
|
|
69
|
+
/**
|
|
70
|
+
* What `now` differs from `kept` in, first difference only, or `undefined`
|
|
71
|
+
* when they are the same program: the same roots in the same order, the same
|
|
72
|
+
* options, and the same source files with the same text.
|
|
73
|
+
*/
|
|
74
|
+
export declare function programDifference(kept: ProgramRecord, now: ProgramRecord): string | undefined;
|
|
75
|
+
/** What a kept answer is read against: the process and the request. */
|
|
76
|
+
export interface AnswerKey {
|
|
77
|
+
/** The directory entries are kept in. */
|
|
78
|
+
dir: string;
|
|
79
|
+
/** The service root the process was scoped to: one entry per root. */
|
|
80
|
+
repoRoot: string;
|
|
81
|
+
/** `requestDigest` of the `init` request the process was scoped with. */
|
|
82
|
+
init: string;
|
|
83
|
+
/** `requestDigest` of the `infer` request. */
|
|
84
|
+
request: string;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The kept entry for `key` when the sidecar, the scope and the request are
|
|
88
|
+
* the ones it was kept for. The programs are not compared here: that needs
|
|
89
|
+
* them built (`servedAnswer` in `index.ts`).
|
|
90
|
+
*/
|
|
91
|
+
export declare function readEntry(key: AnswerKey): {
|
|
92
|
+
programs: ProgramRecord[];
|
|
93
|
+
answer: KeptAnswer;
|
|
94
|
+
} | undefined;
|
|
95
|
+
/** Whether `answer` can be served again: it does not depend on the clock. */
|
|
96
|
+
export declare function keepable(answer: KeptAnswer): boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Everything the compiler holds for `project` as it stands, under `key`: its
|
|
99
|
+
* root files in order, its options, and each source file's path, module
|
|
100
|
+
* format and the sha256 of its text.
|
|
101
|
+
*/
|
|
102
|
+
export declare function programRecord(key: string, project: Project): ProgramRecord;
|
|
103
|
+
/**
|
|
104
|
+
* The first source file of `kept` whose text on disk is not the text it was
|
|
105
|
+
* read with, or `undefined` when every one on disk is the same. Read before any
|
|
106
|
+
* program is built: a changed file, the usual reason an answer is stale, is
|
|
107
|
+
* found for the cost of reading the files, and no program is built to find
|
|
108
|
+
* it. The compiler reads a file without its byte-order mark, and so does
|
|
109
|
+
* this.
|
|
110
|
+
*/
|
|
111
|
+
export declare function changedSource(kept: ProgramRecord): string | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* Build `project`'s program again with nothing in it changed. ts-morph builds
|
|
114
|
+
* a program only after a change, and roots it at every file the project has
|
|
115
|
+
* loaded, the ones earlier builds reached among them: a program built again
|
|
116
|
+
* after its first build has more roots than the first. Setting one file's
|
|
117
|
+
* text to the text it has is the change.
|
|
118
|
+
*/
|
|
119
|
+
export declare function rebuildProgram(project: Project): void;
|
|
120
|
+
/**
|
|
121
|
+
* Keep `answer` for `key`, read in `programs`. Written to a temporary file
|
|
122
|
+
* and renamed, so a reader never sees half an entry. Entries not written to
|
|
123
|
+
* for `ENTRY_MAX_AGE_MS` are deleted on the way.
|
|
124
|
+
*/
|
|
125
|
+
export declare function writeEntry(key: AnswerKey, programs: ProgramRecord[], answer: KeptAnswer): void;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An `infer` answer kept on disk and served again while nothing it was read
|
|
3
|
+
* from has changed (carrick#2843).
|
|
4
|
+
*
|
|
5
|
+
* A scoped re-scan re-analyses a service when one of its imports changed,
|
|
6
|
+
* even when nothing the service's own types are read from did. A generated
|
|
7
|
+
* API client asks hundreds of `infer` requests of a program that a change
|
|
8
|
+
* elsewhere does not touch, and answering them costs tens of seconds each
|
|
9
|
+
* time. This keeps the last answer per service and serves it when the
|
|
10
|
+
* program it was read in is the same program.
|
|
11
|
+
*
|
|
12
|
+
* What keys an answer, all of it compared before one is served:
|
|
13
|
+
* - the sidecar itself: its compiled code, the TypeScript and ts-morph
|
|
14
|
+
* versions it runs and the Node version (`sidecarIdentity`);
|
|
15
|
+
* - the `init` request the process was scoped with, less its id;
|
|
16
|
+
* - the `infer` request, less its id: every item, the extraction config, the
|
|
17
|
+
* model symbols and the server claims;
|
|
18
|
+
* - for each program the request is answered in: its root files in order,
|
|
19
|
+
* its compiler options, and every source file the compiler holds, by path
|
|
20
|
+
* and by the sha256 of its text (`programRecord`).
|
|
21
|
+
*
|
|
22
|
+
* The program is the compiler's whole view: a declaration it could read is a
|
|
23
|
+
* source file in it, and a file that would now resolve in place of another
|
|
24
|
+
* changes the list. To compare it, the lookup builds each program afresh from
|
|
25
|
+
* its tsconfig, adds the roots the stored answer's program had, in order, and
|
|
26
|
+
* compares the result. A program that differs is dropped again, so the
|
|
27
|
+
* inference that follows builds it exactly as it would have with no cache.
|
|
28
|
+
* A program that matches is the one the inference would have left behind, so
|
|
29
|
+
* every later request in the process reads the program it would have read.
|
|
30
|
+
*
|
|
31
|
+
* Not kept: an answer whose unwidened reading ran out of its time budget (it
|
|
32
|
+
* depends on the clock), an answer that read server-framework claims (they
|
|
33
|
+
* are read in a program of their own, which the record does not cover), and
|
|
34
|
+
* an answer of a process whose program a request before it had built (its
|
|
35
|
+
* roots then depend on that request).
|
|
36
|
+
*
|
|
37
|
+
* On only where `CARRICK_INFER_CACHE_DIR` names a directory: local scans set
|
|
38
|
+
* it, and a CI scan, which starts on a clean machine, does not.
|
|
39
|
+
*/
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
41
|
+
import * as fs from 'node:fs';
|
|
42
|
+
import { createRequire } from 'node:module';
|
|
43
|
+
import * as path from 'node:path';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
import { WriteGuard } from './capture/index.js';
|
|
46
|
+
import { listProgramFiles } from './program-files.js';
|
|
47
|
+
/** The variable naming the directory answers are kept in. */
|
|
48
|
+
export const INFER_CACHE_ENV = 'CARRICK_INFER_CACHE_DIR';
|
|
49
|
+
/** Bumped when the entry's shape or what keys it changes. */
|
|
50
|
+
const ENTRY_VERSION = 1;
|
|
51
|
+
/** An entry older than this is deleted when another is written. */
|
|
52
|
+
const ENTRY_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
53
|
+
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
|
54
|
+
/** The directory answers are kept in, or `undefined` when the cache is off. */
|
|
55
|
+
export function inferCacheDir() {
|
|
56
|
+
const dir = process.env[INFER_CACHE_ENV];
|
|
57
|
+
return dir && dir.trim() !== '' ? dir : undefined;
|
|
58
|
+
}
|
|
59
|
+
let identity;
|
|
60
|
+
/**
|
|
61
|
+
* The sidecar that reads the answers: every compiled file of its own, the
|
|
62
|
+
* compiler and ts-morph versions it loads, and the Node version.
|
|
63
|
+
*/
|
|
64
|
+
export function sidecarIdentity() {
|
|
65
|
+
if (identity !== undefined)
|
|
66
|
+
return identity;
|
|
67
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
68
|
+
const files = [];
|
|
69
|
+
const walk = (dir) => {
|
|
70
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
71
|
+
const full = path.join(dir, entry.name);
|
|
72
|
+
if (entry.isDirectory())
|
|
73
|
+
walk(full);
|
|
74
|
+
else if (entry.name.endsWith('.js'))
|
|
75
|
+
files.push(full);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
walk(here);
|
|
79
|
+
const hash = createHash('sha256');
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
hash.update(path.relative(here, file)).update('\0').update(fs.readFileSync(file)).update('\0');
|
|
82
|
+
}
|
|
83
|
+
const require = createRequire(import.meta.url);
|
|
84
|
+
for (const pkg of ['typescript', 'ts-morph']) {
|
|
85
|
+
const manifest = JSON.parse(fs.readFileSync(require.resolve(`${pkg}/package.json`), 'utf8'));
|
|
86
|
+
hash.update(`${pkg}@${manifest.version ?? ''}\0`);
|
|
87
|
+
}
|
|
88
|
+
hash.update(process.version);
|
|
89
|
+
identity = hash.digest('hex');
|
|
90
|
+
return identity;
|
|
91
|
+
}
|
|
92
|
+
/** A request's digest, less the id every request carries. */
|
|
93
|
+
export function requestDigest(request) {
|
|
94
|
+
const { request_id: _id, ...rest } = request;
|
|
95
|
+
return sha256(JSON.stringify(rest));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* What `now` differs from `kept` in, first difference only, or `undefined`
|
|
99
|
+
* when they are the same program: the same roots in the same order, the same
|
|
100
|
+
* options, and the same source files with the same text.
|
|
101
|
+
*/
|
|
102
|
+
export function programDifference(kept, now) {
|
|
103
|
+
if (JSON.stringify(kept.roots) !== JSON.stringify(now.roots)) {
|
|
104
|
+
const at = kept.roots.findIndex((file, i) => now.roots[i] !== file);
|
|
105
|
+
const place = at === -1 ? now.roots.length - 1 : at;
|
|
106
|
+
return `its root files, from root ${place}: ${kept.roots[place] ?? 'none'} kept, ${now.roots[place] ?? 'none'} now (${kept.roots.length} kept, ${now.roots.length} now)`;
|
|
107
|
+
}
|
|
108
|
+
if (kept.options !== now.options)
|
|
109
|
+
return 'its compiler options';
|
|
110
|
+
const before = new Map(kept.sources.map(([file, format, hash]) => [file, `${format}:${hash}`]));
|
|
111
|
+
for (const [file, format, hash] of now.sources) {
|
|
112
|
+
const was = before.get(file);
|
|
113
|
+
if (was === undefined)
|
|
114
|
+
return `${file}, which it now reads`;
|
|
115
|
+
if (was !== `${format}:${hash}`)
|
|
116
|
+
return `${file}, whose text changed`;
|
|
117
|
+
before.delete(file);
|
|
118
|
+
}
|
|
119
|
+
const gone = before.keys().next();
|
|
120
|
+
if (!gone.done)
|
|
121
|
+
return `${gone.value}, which it no longer reads`;
|
|
122
|
+
if (JSON.stringify(kept.sources.map(([f]) => f)) !== JSON.stringify(now.sources.map(([f]) => f))) {
|
|
123
|
+
return 'the order of its source files';
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const entryPath = (key) => path.join(key.dir, `${sha256(key.repoRoot)}.json`);
|
|
128
|
+
/**
|
|
129
|
+
* The kept entry for `key` when the sidecar, the scope and the request are
|
|
130
|
+
* the ones it was kept for. The programs are not compared here: that needs
|
|
131
|
+
* them built (`servedAnswer` in `index.ts`).
|
|
132
|
+
*/
|
|
133
|
+
export function readEntry(key) {
|
|
134
|
+
let entry;
|
|
135
|
+
try {
|
|
136
|
+
entry = JSON.parse(fs.readFileSync(entryPath(key), 'utf8'));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
if (entry.version !== ENTRY_VERSION ||
|
|
142
|
+
entry.identity !== sidecarIdentity() ||
|
|
143
|
+
entry.init !== key.init ||
|
|
144
|
+
entry.request !== key.request ||
|
|
145
|
+
!Array.isArray(entry.programs) ||
|
|
146
|
+
!entry.answer) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
return { programs: entry.programs, answer: entry.answer };
|
|
150
|
+
}
|
|
151
|
+
/** Whether `answer` can be served again: it does not depend on the clock. */
|
|
152
|
+
export function keepable(answer) {
|
|
153
|
+
return !(answer.inferred_types ?? []).some((t) => t.unwidened_out_of_budget === true);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Everything the compiler holds for `project` as it stands, under `key`: its
|
|
157
|
+
* root files in order, its options, and each source file's path, module
|
|
158
|
+
* format and the sha256 of its text.
|
|
159
|
+
*/
|
|
160
|
+
export function programRecord(key, project) {
|
|
161
|
+
const program = project.getProgram().compilerObject;
|
|
162
|
+
return {
|
|
163
|
+
key,
|
|
164
|
+
roots: listProgramFiles(project),
|
|
165
|
+
options: JSON.stringify(program.getCompilerOptions()),
|
|
166
|
+
sources: program
|
|
167
|
+
.getSourceFiles()
|
|
168
|
+
.map((file) => [file.fileName, String(file.impliedNodeFormat ?? ''), sha256(file.text)]),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The first source file of `kept` whose text on disk is not the text it was
|
|
173
|
+
* read with, or `undefined` when every one on disk is the same. Read before any
|
|
174
|
+
* program is built: a changed file, the usual reason an answer is stale, is
|
|
175
|
+
* found for the cost of reading the files, and no program is built to find
|
|
176
|
+
* it. The compiler reads a file without its byte-order mark, and so does
|
|
177
|
+
* this.
|
|
178
|
+
*/
|
|
179
|
+
export function changedSource(kept) {
|
|
180
|
+
for (const [file, , hash] of kept.sources) {
|
|
181
|
+
let text;
|
|
182
|
+
try {
|
|
183
|
+
text = fs.readFileSync(file, 'utf8');
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Not on disk: ts-morph serves the default library from memory. A
|
|
187
|
+
// file that is really gone is found when the program is built.
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (text.charCodeAt(0) === 0xfeff)
|
|
191
|
+
text = text.slice(1);
|
|
192
|
+
if (sha256(text) !== hash)
|
|
193
|
+
return `${file}, whose text changed`;
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Build `project`'s program again with nothing in it changed. ts-morph builds
|
|
199
|
+
* a program only after a change, and roots it at every file the project has
|
|
200
|
+
* loaded, the ones earlier builds reached among them: a program built again
|
|
201
|
+
* after its first build has more roots than the first. Setting one file's
|
|
202
|
+
* text to the text it has is the change.
|
|
203
|
+
*/
|
|
204
|
+
export function rebuildProgram(project) {
|
|
205
|
+
const file = project.getSourceFiles()[0];
|
|
206
|
+
if (!file)
|
|
207
|
+
return;
|
|
208
|
+
file.replaceWithText(file.getFullText());
|
|
209
|
+
project.getProgram().compilerObject;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Keep `answer` for `key`, read in `programs`. Written to a temporary file
|
|
213
|
+
* and renamed, so a reader never sees half an entry. Entries not written to
|
|
214
|
+
* for `ENTRY_MAX_AGE_MS` are deleted on the way.
|
|
215
|
+
*/
|
|
216
|
+
export function writeEntry(key, programs, answer) {
|
|
217
|
+
// The cache directory is the only place this writes, and never the
|
|
218
|
+
// service it read (carrick#1748).
|
|
219
|
+
const guard = WriteGuard.of({ dirs: [key.dir], protect: [key.repoRoot] });
|
|
220
|
+
guard.mkdir(key.dir);
|
|
221
|
+
const entry = {
|
|
222
|
+
version: ENTRY_VERSION,
|
|
223
|
+
identity: sidecarIdentity(),
|
|
224
|
+
init: key.init,
|
|
225
|
+
request: key.request,
|
|
226
|
+
programs,
|
|
227
|
+
answer,
|
|
228
|
+
};
|
|
229
|
+
const target = entryPath(key);
|
|
230
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
231
|
+
guard.writeFile(temporary, JSON.stringify(entry));
|
|
232
|
+
guard.rename(temporary, target);
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
for (const name of fs.readdirSync(key.dir)) {
|
|
235
|
+
const file = path.join(key.dir, name);
|
|
236
|
+
try {
|
|
237
|
+
if (now - fs.statSync(file).mtimeMs > ENTRY_MAX_AGE_MS)
|
|
238
|
+
guard.remove(file);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// Another process removed it first.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -39,6 +39,7 @@ import * as crypto from 'node:crypto';
|
|
|
39
39
|
import * as fs from 'node:fs';
|
|
40
40
|
import * as path from 'node:path';
|
|
41
41
|
import { ts } from 'ts-morph';
|
|
42
|
+
import { holdsResult, resultCandidatesOf } from './result-candidates.js';
|
|
42
43
|
import { PathNames, declaringFileName } from './position-rows.js';
|
|
43
44
|
import { memberDeclaration } from './declaration-attribution.js';
|
|
44
45
|
/** The methods a `verb` claim may name, and a method key may accept. */
|
|
@@ -771,9 +772,10 @@ export function fullSurfaceSha256(surfaces) {
|
|
|
771
772
|
.filter(surface => surface.reason === undefined && surface.exports.length > 0)
|
|
772
773
|
.map(surface => [surface.package, surface.exports])
|
|
773
774
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
774
|
-
// A member's `handlers` (carrick#2674)
|
|
775
|
-
//
|
|
776
|
-
|
|
775
|
+
// A member's `handlers` (carrick#2674) and a signature's `result_candidates`
|
|
776
|
+
// (carrick#2817) are out of the hash at every depth, so a package's key does
|
|
777
|
+
// not change when the lister learns to list them.
|
|
778
|
+
const text = JSON.stringify(listed, (key, value) => key === 'handlers' || key === 'result_candidates' ? undefined : value);
|
|
777
779
|
return crypto.createHash('sha256').update(text).digest('hex');
|
|
778
780
|
}
|
|
779
781
|
function exportKey(check) {
|
|
@@ -1365,6 +1367,8 @@ class DeclarationReader {
|
|
|
1365
1367
|
* `claim_invalid`.
|
|
1366
1368
|
*/
|
|
1367
1369
|
judgeHttp(receiver, claim) {
|
|
1370
|
+
if (claim.kind === 'result')
|
|
1371
|
+
return this.judgeHttpResult(receiver, claim);
|
|
1368
1372
|
if (claim.kind === 'make') {
|
|
1369
1373
|
const base = claim.base;
|
|
1370
1374
|
if (claim.form !== 'call' ||
|
|
@@ -1624,8 +1628,38 @@ class DeclarationReader {
|
|
|
1624
1628
|
return { outcome: this.judgeOp(receiver, claim, rules) };
|
|
1625
1629
|
case 'reserved':
|
|
1626
1630
|
return { outcome: this.judgeReserved(receiver, claim) };
|
|
1631
|
+
case 'result':
|
|
1632
|
+
return { outcome: this.resultOutcome(this.ownCallee(receiver, claim.path ?? [], claim.member, 'call'), claim.result) };
|
|
1627
1633
|
}
|
|
1628
1634
|
}
|
|
1635
|
+
/**
|
|
1636
|
+
* A `result` claim (carrick#2817): the claimed result is a candidate of at
|
|
1637
|
+
* least one signature the callee declares, by rule R (`result-candidates.ts`).
|
|
1638
|
+
* Anything else, including a call that returns nothing, is
|
|
1639
|
+
* `result_not_candidate`.
|
|
1640
|
+
*/
|
|
1641
|
+
resultOutcome(callee, shape) {
|
|
1642
|
+
if ('failure' in callee)
|
|
1643
|
+
return callee.failure;
|
|
1644
|
+
for (const signature of callee.signatures) {
|
|
1645
|
+
const candidates = resultCandidatesOf(this.checker, signature);
|
|
1646
|
+
if (candidates !== undefined && holdsResult(candidates, shape))
|
|
1647
|
+
return VERIFIED;
|
|
1648
|
+
}
|
|
1649
|
+
return failed('result_not_candidate');
|
|
1650
|
+
}
|
|
1651
|
+
/** A `result` claim on an HTTP receiver: `path` walked from it, then `member` of what that reaches. */
|
|
1652
|
+
judgeHttpResult(receiver, claim) {
|
|
1653
|
+
let holder = receiver;
|
|
1654
|
+
for (const hop of claim.path ?? []) {
|
|
1655
|
+
const property = this.declaredProperty(holder, hop);
|
|
1656
|
+
if (!property)
|
|
1657
|
+
return failed('member_missing');
|
|
1658
|
+
holder = this.checker.getTypeOfSymbol(property);
|
|
1659
|
+
}
|
|
1660
|
+
const callee = claim.member === null ? this.callSignatures(holder) : this.callableProperty(holder, claim.member);
|
|
1661
|
+
return this.resultOutcome(callee, claim.result);
|
|
1662
|
+
}
|
|
1629
1663
|
/**
|
|
1630
1664
|
* `member` (or the export itself, when null) is callable (`call`) or
|
|
1631
1665
|
* constructible (`new`) through signatures the receiver's home packages
|
|
@@ -3356,7 +3390,11 @@ class DeclarationReader {
|
|
|
3356
3390
|
param.literals = [...literals].sort();
|
|
3357
3391
|
params.push(param);
|
|
3358
3392
|
}
|
|
3359
|
-
|
|
3393
|
+
const entry = { params, returns: this.printed(this.checker.getReturnTypeOfSignature(signature)) };
|
|
3394
|
+
const candidates = resultCandidatesOf(this.checker, signature);
|
|
3395
|
+
if (candidates !== undefined)
|
|
3396
|
+
entry.result_candidates = candidates;
|
|
3397
|
+
listed.push(entry);
|
|
3360
3398
|
}
|
|
3361
3399
|
return listed;
|
|
3362
3400
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every node of a source file, walked once per file (carrick#1935).
|
|
3
|
+
*
|
|
4
|
+
* The inferrer locates each item by a line, a text near a line, or a span.
|
|
5
|
+
* Each of those lookups used to call `sourceFile.getDescendants()`, which
|
|
6
|
+
* walks and wraps every node of the file, once per item: on a generated
|
|
7
|
+
* client of 8,000+ lines that was 71% of an infer request's time. Here the
|
|
8
|
+
* file is walked once, the positions are read once, and a lookup wraps only
|
|
9
|
+
* the nodes it returns.
|
|
10
|
+
*
|
|
11
|
+
* The walk is ts-morph's own (`getDescendants()` iterates the same compiler
|
|
12
|
+
* nodes in the same order, tokens, syntax lists and its comment nodes
|
|
13
|
+
* included), and a node is wrapped through the project's own factory, so a
|
|
14
|
+
* lookup returns the very wrapper the walk returned.
|
|
15
|
+
*
|
|
16
|
+
* The index belongs to the compiler's source file node, not to the file's
|
|
17
|
+
* name: replacing a file's text gives it a new node, so a file rewritten for
|
|
18
|
+
* one reading and then restored is indexed again each time.
|
|
19
|
+
*/
|
|
20
|
+
import { Node, SyntaxKind, ts, type SourceFile } from 'ts-morph';
|
|
21
|
+
/** One node of the file, with what the lookups read of it. */
|
|
22
|
+
export interface IndexedNode {
|
|
23
|
+
node: ts.Node;
|
|
24
|
+
kind: SyntaxKind;
|
|
25
|
+
/** `getStart()`: the first character of its own text. */
|
|
26
|
+
start: number;
|
|
27
|
+
/** `getEnd()`. */
|
|
28
|
+
end: number;
|
|
29
|
+
/** `getStartLineNumber()`. */
|
|
30
|
+
line: number;
|
|
31
|
+
/** Its place in `getDescendants()` order. */
|
|
32
|
+
order: number;
|
|
33
|
+
}
|
|
34
|
+
/** How many file indexes have been built: tests read it to prove reuse. */
|
|
35
|
+
export declare function nodeIndexBuilds(): number;
|
|
36
|
+
/** The ts-morph node for an indexed one. */
|
|
37
|
+
export declare function wrap<T extends Node = Node>(sourceFile: SourceFile, entry: IndexedNode): T;
|
|
38
|
+
/** Every descendant, in `getDescendants()` order. */
|
|
39
|
+
export declare function descendantsOf(sourceFile: SourceFile): readonly IndexedNode[];
|
|
40
|
+
/**
|
|
41
|
+
* The call expressions, in `getDescendantsOfKind(SyntaxKind.CallExpression)`
|
|
42
|
+
* order.
|
|
43
|
+
*/
|
|
44
|
+
export declare function callsOf(sourceFile: SourceFile): readonly IndexedNode[];
|
|
45
|
+
/** The descendants that start on `line`, in walk order. */
|
|
46
|
+
export declare function descendantsOnLine(sourceFile: SourceFile, line: number): readonly IndexedNode[];
|
|
47
|
+
/**
|
|
48
|
+
* The descendants that start on a line from `first` to `last`, in walk
|
|
49
|
+
* order. Walk order is not line order (a doc comment starts before the node
|
|
50
|
+
* that owns it but follows it in the walk), so the lines' lists are merged
|
|
51
|
+
* back into walk order.
|
|
52
|
+
*/
|
|
53
|
+
export declare function descendantsOnLines(sourceFile: SourceFile, first: number, last: number): IndexedNode[];
|