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.
Files changed (70) hide show
  1. package/dist/contract.d.ts +6 -0
  2. package/dist/contract.js.map +1 -1
  3. package/dist/hook/post-edit.d.ts +10 -5
  4. package/dist/hook/post-edit.js +80 -23
  5. package/dist/hook/post-edit.js.map +1 -1
  6. package/dist/hook/refresh.d.ts +23 -3
  7. package/dist/hook/refresh.js +48 -6
  8. package/dist/hook/refresh.js.map +1 -1
  9. package/dist/hook/reuse.d.ts +7 -21
  10. package/dist/hook/reuse.js +23 -36
  11. package/dist/hook/reuse.js.map +1 -1
  12. package/dist/hook/session-start.js +15 -2
  13. package/dist/hook/session-start.js.map +1 -1
  14. package/dist/hook/stop.js.map +1 -1
  15. package/dist/init/agents-section.js +1 -1
  16. package/dist/init/agents-section.js.map +1 -1
  17. package/dist/init/doctor.js +4 -0
  18. package/dist/init/doctor.js.map +1 -1
  19. package/dist/init/hosted.d.ts +1 -0
  20. package/dist/init/hosted.js +4 -1
  21. package/dist/init/hosted.js.map +1 -1
  22. package/dist/init/remove.js +7 -1
  23. package/dist/init/remove.js.map +1 -1
  24. package/dist/init/repo-copies.js +3 -1
  25. package/dist/init/repo-copies.js.map +1 -1
  26. package/dist/init/run.d.ts +1 -1
  27. package/dist/init/run.js +10 -2
  28. package/dist/init/run.js.map +1 -1
  29. package/dist/init/task-skills.d.ts +22 -1
  30. package/dist/init/task-skills.js +65 -1
  31. package/dist/init/task-skills.js.map +1 -1
  32. package/dist/render.d.ts +41 -16
  33. package/dist/render.js +91 -102
  34. package/dist/render.js.map +1 -1
  35. package/package.json +10 -9
  36. package/plugin/.claude-plugin/plugin.json +1 -1
  37. package/sidecar/dist/src/capture/anchors.js +18 -1
  38. package/sidecar/dist/src/capture/api.d.ts +17 -4
  39. package/sidecar/dist/src/capture/check-workspace.js +1 -1
  40. package/sidecar/dist/src/capture/check.d.ts +44 -0
  41. package/sidecar/dist/src/capture/check.js +112 -10
  42. package/sidecar/dist/src/capture/index.js +18 -0
  43. package/sidecar/dist/src/capture/installed-package.d.ts +5 -0
  44. package/sidecar/dist/src/capture/installed-package.js +64 -6
  45. package/sidecar/dist/src/capture/machine-paths.d.ts +44 -0
  46. package/sidecar/dist/src/capture/machine-paths.js +97 -0
  47. package/sidecar/dist/src/capture/node-builder.d.ts +2 -0
  48. package/sidecar/dist/src/capture/node-builder.js +1 -1
  49. package/sidecar/dist/src/capture/paths-rewrite.js +3 -3
  50. package/sidecar/dist/src/capture/result-label.d.ts +42 -0
  51. package/sidecar/dist/src/capture/result-label.js +91 -0
  52. package/sidecar/dist/src/capture/self-check.js +13 -2
  53. package/sidecar/dist/src/index.js +117 -5
  54. package/sidecar/dist/src/infer-cache.d.ts +125 -0
  55. package/sidecar/dist/src/infer-cache.js +244 -0
  56. package/sidecar/dist/src/library-claims.d.ts +35 -3
  57. package/sidecar/dist/src/library-claims.js +417 -61
  58. package/sidecar/dist/src/node-index.d.ts +53 -0
  59. package/sidecar/dist/src/node-index.js +100 -0
  60. package/sidecar/dist/src/project-loader.d.ts +11 -0
  61. package/sidecar/dist/src/project-loader.js +15 -0
  62. package/sidecar/dist/src/result-candidates.d.ts +28 -0
  63. package/sidecar/dist/src/result-candidates.js +209 -0
  64. package/sidecar/dist/src/type-inferrer.d.ts +27 -0
  65. package/sidecar/dist/src/type-inferrer.js +175 -43
  66. package/sidecar/dist/src/types.d.ts +77 -1
  67. package/sidecar/dist/src/unwidened.js +10 -1
  68. package/sidecar/dist/src/validators.d.ts +931 -197
  69. package/sidecar/dist/src/validators.js +34 -0
  70. 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 = `unresolved pinned external '${blamedExternal}' on an installed checkout`;
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 = `unresolved pinned external '${blamedExternal}' on an installed checkout`;
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 = [...byProject(request.requests, (item) => item.file_path)].map(([key, items]) => {
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
- return {
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
+ }