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.
Files changed (64) 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/installed-package.d.ts +5 -0
  43. package/sidecar/dist/src/capture/installed-package.js +64 -6
  44. package/sidecar/dist/src/capture/paths-rewrite.js +3 -3
  45. package/sidecar/dist/src/capture/result-label.d.ts +42 -0
  46. package/sidecar/dist/src/capture/result-label.js +91 -0
  47. package/sidecar/dist/src/capture/self-check.js +13 -2
  48. package/sidecar/dist/src/index.js +116 -4
  49. package/sidecar/dist/src/infer-cache.d.ts +125 -0
  50. package/sidecar/dist/src/infer-cache.js +244 -0
  51. package/sidecar/dist/src/library-claims.js +42 -4
  52. package/sidecar/dist/src/node-index.d.ts +53 -0
  53. package/sidecar/dist/src/node-index.js +100 -0
  54. package/sidecar/dist/src/project-loader.d.ts +11 -0
  55. package/sidecar/dist/src/project-loader.js +15 -0
  56. package/sidecar/dist/src/result-candidates.d.ts +28 -0
  57. package/sidecar/dist/src/result-candidates.js +209 -0
  58. package/sidecar/dist/src/type-inferrer.d.ts +27 -0
  59. package/sidecar/dist/src/type-inferrer.js +175 -43
  60. package/sidecar/dist/src/types.d.ts +52 -0
  61. package/sidecar/dist/src/unwidened.js +10 -1
  62. package/sidecar/dist/src/validators.d.ts +919 -197
  63. package/sidecar/dist/src/validators.js +32 -0
  64. 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 = [...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
@@ -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) is out of the hash at every depth, so
775
- // a package's key does not change when the lister learns to list them.
776
- const text = JSON.stringify(listed, (key, value) => (key === 'handlers' ? undefined : value));
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
- listed.push({ params, returns: this.printed(this.checker.getReturnTypeOfSignature(signature)) });
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[];