carrick 0.3.133 → 0.3.135
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/index.js +18 -0
- 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/machine-paths.d.ts +44 -0
- package/sidecar/dist/src/capture/machine-paths.js +97 -0
- package/sidecar/dist/src/capture/node-builder.d.ts +2 -0
- package/sidecar/dist/src/capture/node-builder.js +1 -1
- 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 +117 -5
- 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.d.ts +35 -3
- package/sidecar/dist/src/library-claims.js +417 -61
- 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 +77 -1
- package/sidecar/dist/src/unwidened.js +10 -1
- package/sidecar/dist/src/validators.d.ts +931 -197
- package/sidecar/dist/src/validators.js +34 -0
- package/templates/skills/carrick-reuse.md +11 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a labelled call's result (carrick#2818).
|
|
3
|
+
*
|
|
4
|
+
* A request library's call rarely returns the response body itself: the body
|
|
5
|
+
* sits in a member of what comes back (`data`), behind a parse method
|
|
6
|
+
* (`json()`), or is the whole value. The library store labels which, per
|
|
7
|
+
* operation, and the scanner sends the label on the consumer's infer anchor.
|
|
8
|
+
* This reads the labelled part of the located call's type, so the contract the
|
|
9
|
+
* consumer states is the body and never the library's envelope.
|
|
10
|
+
*
|
|
11
|
+
* The label is one of:
|
|
12
|
+
* - `{ whole: true }`: the awaited type;
|
|
13
|
+
* - `{ member: [m] }`: `m` on every non-nullish constituent of the awaited
|
|
14
|
+
* type, `undefined` and `null` removed from what is read;
|
|
15
|
+
* - `{ parse: p }`: the awaited return of calling `p()`, read off the type as
|
|
16
|
+
* the call returns it (a promise-like with a `json()` method is a thenable,
|
|
17
|
+
* and awaiting it first would drop the method).
|
|
18
|
+
*
|
|
19
|
+
* A label the type cannot satisfy abstains with `result_member_missing`.
|
|
20
|
+
*/
|
|
21
|
+
import ts from 'typescript';
|
|
22
|
+
const NULLISH = ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Void;
|
|
23
|
+
/** The constituents of a union, or the type alone. */
|
|
24
|
+
export function constituentsOf(type) {
|
|
25
|
+
return type.isUnion() ? type.types : [type];
|
|
26
|
+
}
|
|
27
|
+
function missing(what) {
|
|
28
|
+
return { abstain: `result_member_missing: ${what}` };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Read `label` off a call's type. `raw` is the type as the call returns it and
|
|
32
|
+
* `awaited` the type after the transport unwrap; `at` is the node properties
|
|
33
|
+
* are instantiated at.
|
|
34
|
+
*/
|
|
35
|
+
export function readResultLabel(checker, raw, awaited, label, at) {
|
|
36
|
+
if ('whole' in label)
|
|
37
|
+
return { type: awaited };
|
|
38
|
+
if ('member' in label)
|
|
39
|
+
return readMember(checker, awaited, label.member[0], at);
|
|
40
|
+
return readParse(checker, raw, awaited, label.parse, at);
|
|
41
|
+
}
|
|
42
|
+
function readMember(checker, awaited, name, at) {
|
|
43
|
+
const read = [];
|
|
44
|
+
for (const constituent of constituentsOf(awaited)) {
|
|
45
|
+
if (constituent.flags & NULLISH)
|
|
46
|
+
continue;
|
|
47
|
+
const property = checker.getPropertyOfType(constituent, name);
|
|
48
|
+
if (!property)
|
|
49
|
+
return missing(`the labelled member '${name}' is not on every constituent`);
|
|
50
|
+
const held = checker.getTypeOfSymbolAtLocation(property, at);
|
|
51
|
+
for (const part of constituentsOf(held)) {
|
|
52
|
+
if (!(part.flags & NULLISH))
|
|
53
|
+
read.push(part);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (read.length === 0)
|
|
57
|
+
return missing(`the labelled member '${name}' holds no value`);
|
|
58
|
+
if (read.length === 1)
|
|
59
|
+
return { type: read[0] };
|
|
60
|
+
const union = checker.getUnionType;
|
|
61
|
+
// Without the checker's own union the members cannot be joined honestly;
|
|
62
|
+
// abstaining beats publishing the first of them.
|
|
63
|
+
if (!union)
|
|
64
|
+
return missing(`the labelled member '${name}' holds several types`);
|
|
65
|
+
return { type: union.call(checker, read) };
|
|
66
|
+
}
|
|
67
|
+
function readParse(checker, raw, awaited, name, at) {
|
|
68
|
+
// The method is looked up on the call's own type first: it is the thenable
|
|
69
|
+
// that carries `json()`, and its awaited value does not.
|
|
70
|
+
for (const holder of [raw, awaited]) {
|
|
71
|
+
const method = checker.getPropertyOfType(holder, name);
|
|
72
|
+
if (!method)
|
|
73
|
+
continue;
|
|
74
|
+
const signature = checker
|
|
75
|
+
.getSignaturesOfType(checker.getTypeOfSymbolAtLocation(method, at), ts.SignatureKind.Call)
|
|
76
|
+
.find((candidate) => candidate.parameters.every((p) => isOptional(p)));
|
|
77
|
+
if (!signature)
|
|
78
|
+
continue;
|
|
79
|
+
const returned = checker.getReturnTypeOfSignature(signature);
|
|
80
|
+
return { type: checker.getAwaitedType(returned) ?? returned };
|
|
81
|
+
}
|
|
82
|
+
return missing(`the labelled parse method '${name}' is not callable with no arguments`);
|
|
83
|
+
}
|
|
84
|
+
function isOptional(parameter) {
|
|
85
|
+
const declaration = parameter.valueDeclaration;
|
|
86
|
+
return (!!declaration &&
|
|
87
|
+
ts.isParameter(declaration) &&
|
|
88
|
+
(declaration.questionToken !== undefined ||
|
|
89
|
+
declaration.initializer !== undefined ||
|
|
90
|
+
declaration.dotDotDotToken !== undefined));
|
|
91
|
+
}
|
|
@@ -232,6 +232,17 @@ function runSelfCheck(args, treeFiles, repaired) {
|
|
|
232
232
|
* because the module that is missing is no longer imported to be reported.
|
|
233
233
|
* Fail closed: the alias keeps the refusal it had before the repair.
|
|
234
234
|
*/
|
|
235
|
+
/**
|
|
236
|
+
* Detail for a pinned external that does not resolve on an installed checkout.
|
|
237
|
+
* A subpath on the specifier means the rewrite found no entry of the package
|
|
238
|
+
* exporting a declaration from that file, so it named the file itself.
|
|
239
|
+
*/
|
|
240
|
+
function unresolvedExternalDetail(spec) {
|
|
241
|
+
const pkg = packageNameOf(spec);
|
|
242
|
+
const subpath = spec.slice(pkg.length + 1);
|
|
243
|
+
const base = `unresolved pinned external '${spec}' on an installed checkout`;
|
|
244
|
+
return subpath ? `${base}: no entry of '${pkg}' exports a declaration from '${subpath}'` : base;
|
|
245
|
+
}
|
|
235
246
|
function repairedNameFailure(repaired, file, diagnostic, message) {
|
|
236
247
|
if (!repaired || diagnostic.code !== 2304)
|
|
237
248
|
return undefined;
|
|
@@ -432,7 +443,7 @@ function checkedRecord(anchor, ctx) {
|
|
|
432
443
|
}
|
|
433
444
|
else {
|
|
434
445
|
outcome = 'decayed_internal';
|
|
435
|
-
detail =
|
|
446
|
+
detail = unresolvedExternalDetail(blamedExternal);
|
|
436
447
|
}
|
|
437
448
|
}
|
|
438
449
|
else {
|
|
@@ -452,7 +463,7 @@ function checkedRecord(anchor, ctx) {
|
|
|
452
463
|
}
|
|
453
464
|
else {
|
|
454
465
|
outcome = 'decayed_internal';
|
|
455
|
-
detail =
|
|
466
|
+
detail = unresolvedExternalDetail(blamedExternal);
|
|
456
467
|
}
|
|
457
468
|
}
|
|
458
469
|
return {
|
|
@@ -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
|
|
@@ -525,7 +637,7 @@ function handleListLibrarySurface(request) {
|
|
|
525
637
|
const { claimsVerifier } = projectComponents();
|
|
526
638
|
const fromDir = path.resolve(projectLoader.getRepoRoot(), request.from_dir);
|
|
527
639
|
log(`Listing the declared surface of ${request.packages.length} specifier(s) from ${fromDir}`);
|
|
528
|
-
const { surfaces, surface_sha256 } = claimsVerifier.listSurface(fromDir, request.packages, request.max_entries ?? SURFACE_MAX_ENTRIES, request.exports ?? {});
|
|
640
|
+
const { surfaces, surface_sha256 } = claimsVerifier.listSurface(fromDir, request.packages, request.max_entries ?? SURFACE_MAX_ENTRIES, request.exports ?? {}, { handlers: request.handlers, resultCandidates: request.result_candidates });
|
|
529
641
|
return { request_id: request.request_id, status: 'success', surfaces, surface_sha256 };
|
|
530
642
|
}
|
|
531
643
|
catch (err) {
|
|
@@ -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
|
+
}
|