carrick 0.3.107 → 0.3.109

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 (40) hide show
  1. package/package.json +9 -10
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/sidecar/dist/src/capture/api.d.ts +15 -0
  4. package/sidecar/dist/src/capture/check-classify.d.ts +9 -0
  5. package/sidecar/dist/src/capture/check-classify.js +20 -0
  6. package/sidecar/dist/src/capture/check-deep.js +2 -1
  7. package/sidecar/dist/src/capture/check-fields.d.ts +24 -0
  8. package/sidecar/dist/src/capture/check-fields.js +54 -36
  9. package/sidecar/dist/src/capture/check-union.d.ts +40 -0
  10. package/sidecar/dist/src/capture/check-union.js +92 -0
  11. package/sidecar/dist/src/capture/check-workspace.d.ts +1 -0
  12. package/sidecar/dist/src/capture/check-workspace.js +13 -2
  13. package/sidecar/dist/src/capture/check.js +5 -0
  14. package/sidecar/dist/src/capture/compiler-options.d.ts +39 -0
  15. package/sidecar/dist/src/capture/compiler-options.js +100 -0
  16. package/sidecar/dist/src/capture/deno-project.js +3 -2
  17. package/sidecar/dist/src/capture/index.d.ts +1 -0
  18. package/sidecar/dist/src/capture/index.js +90 -48
  19. package/sidecar/dist/src/capture/installed-package.js +3 -2
  20. package/sidecar/dist/src/capture/self-check.js +3 -2
  21. package/sidecar/dist/src/definition-resolver.d.ts +5 -8
  22. package/sidecar/dist/src/definition-resolver.js +8 -9
  23. package/sidecar/dist/src/index.js +124 -11
  24. package/sidecar/dist/src/infer-timing.d.ts +54 -0
  25. package/sidecar/dist/src/infer-timing.js +124 -0
  26. package/sidecar/dist/src/print-type.d.ts +48 -0
  27. package/sidecar/dist/src/print-type.js +71 -0
  28. package/sidecar/dist/src/printed-names.js +2 -1
  29. package/sidecar/dist/src/program-files.d.ts +44 -0
  30. package/sidecar/dist/src/program-files.js +64 -0
  31. package/sidecar/dist/src/project-loader.d.ts +5 -0
  32. package/sidecar/dist/src/project-loader.js +14 -5
  33. package/sidecar/dist/src/retype.d.ts +4 -2
  34. package/sidecar/dist/src/retype.js +16 -5
  35. package/sidecar/dist/src/type-inferrer.d.ts +157 -1
  36. package/sidecar/dist/src/type-inferrer.js +444 -20
  37. package/sidecar/dist/src/types.d.ts +104 -2
  38. package/sidecar/dist/src/validators.d.ts +166 -80
  39. package/sidecar/dist/src/validators.js +13 -0
  40. package/templates/skills/carrick-reuse.md +6 -5
@@ -5,6 +5,7 @@ import * as path from 'node:path';
5
5
  import { pathToFileURL, fileURLToPath } from 'node:url';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { execFileSync } from 'node:child_process';
8
+ import { sidecarCompilerOptions } from './compiler-options.js';
8
9
  import { rewriteSpecifiers } from './specifiers.js';
9
10
  import { WriteGuard } from './guarded-fs.js';
10
11
  function readConfig(file) {
@@ -452,11 +453,11 @@ export class DenoProject {
452
453
  /** Runtime declarations belong to their producer, not the checker's globals. */
453
454
  function isolateRuntime(guard, typesDir, files, runtimeRel) {
454
455
  const runtimePath = path.join(typesDir, runtimeRel);
455
- const program = ts.createProgram(files.map(file => path.join(typesDir, file)), {
456
+ const program = ts.createProgram(files.map(file => path.join(typesDir, file)), sidecarCompilerOptions({
456
457
  strict: true, skipLibCheck: true, target: ts.ScriptTarget.ESNext,
457
458
  module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler,
458
459
  types: [],
459
- });
460
+ }));
460
461
  const checker = program.getTypeChecker();
461
462
  const runtime = program.getSourceFile(runtimePath);
462
463
  const retained = new Set();
@@ -30,6 +30,7 @@
30
30
  import type { CaptureStubOptions, CaptureStubResult } from './api.js';
31
31
  export type { CaptureStubOptions, CaptureStubResult } from './api.js';
32
32
  export { DenoProject, findDenoConfig } from './deno-project.js';
33
+ export { sidecarCompilerOptions } from './compiler-options.js';
33
34
  export { serviceConfigPath } from './project-references.js';
34
35
  export { findServiceTsconfig } from './service-config.js';
35
36
  export { runCheck } from './check.js';
@@ -42,7 +42,9 @@ import { emitsAlike, ProjectGraph } from './project-references.js';
42
42
  import { findServiceTsconfig } from './service-config.js';
43
43
  import { placeEmittedTree, surfaceModuleInTree } from './outside-root.js';
44
44
  import { WriteGuard } from './guarded-fs.js';
45
+ import { sidecarCompilerOptions } from './compiler-options.js';
45
46
  export { DenoProject, findDenoConfig } from './deno-project.js';
47
+ export { sidecarCompilerOptions } from './compiler-options.js';
46
48
  export { serviceConfigPath } from './project-references.js';
47
49
  export { findServiceTsconfig } from './service-config.js';
48
50
  // v2 check core ("tsc as the judge"). Same bundle, same seam: the sidecar
@@ -256,7 +258,8 @@ export function captureStub(opts) {
256
258
  }
257
259
  // ---- Phase A: analysis program over placeholder entry + anchor sources ----
258
260
  let resolved;
259
- const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath, guard };
261
+ const progress = opts.onProgress ?? (() => { });
262
+ const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath, guard, progress };
260
263
  try {
261
264
  resolved = ownerGroups && emitProject
262
265
  ? resolveAnchorsByOwner(opts, ownerGroups, emitProject, analysisCtx, errors)
@@ -277,16 +280,11 @@ export function captureStub(opts) {
277
280
  }
278
281
  const scratch = WriteGuard.scratch('carrick-capture-v2-');
279
282
  const staging = scratch.dir;
280
- const emitted = new Map();
281
- // Input .d.ts files (ambient stubs, augmentation declarations, local
282
- // hand-written declarations in the import closure) are never re-emitted by
283
- // tsc; they must ship verbatim or the tree's references to them dangle.
284
- const declarationSources = new Map();
285
- const sourceByEmitted = new Map();
286
- let emitPartial = false;
283
+ let written;
287
284
  try {
285
+ progress('emit', 'emitting declarations');
288
286
  guard.writeFile(entryPath, entryLines.join('\n') + '\n');
289
- const emitOptions = {
287
+ const emitOptions = sidecarCompilerOptions({
290
288
  ...parsed.options,
291
289
  // The load-bearing trio: emit declarations without checking, so
292
290
  // type-error-laden and bare (no node_modules) checkouts still emit.
@@ -299,36 +297,14 @@ export function captureStub(opts) {
299
297
  incremental: false,
300
298
  outDir: staging,
301
299
  rootDir: entryDir,
302
- };
303
- const program = ts.createProgram([entryPath, ...augmentationSources], emitOptions, deno?.host(emitOptions));
304
- const emitResult = program.emit(undefined, (fileName, text, _bom, _error, sources) => {
305
- emitted.set(fileName, text);
306
- if (sources?.[0])
307
- sourceByEmitted.set(path.relative(staging, fileName).split(path.sep).join('/'), sources[0].fileName);
308
- }, undefined,
309
- /* emitOnlyDtsFiles */ true);
310
- // emitSkipped is PER-PROGRAM even when only one file's declaration emit
311
- // failed (e.g. TS4023 from a hand-rolled ambient stub shadowing a real
312
- // package): every other file's .d.ts was still written to the callback.
313
- // Fail wholesale only when nothing at all emitted; otherwise keep the
314
- // emitted subset and demote exactly the aliases it cannot support.
315
- if (emitResult.emitSkipped && emitted.size === 0) {
316
- return fail(stubDir, packageName, ['declaration emit was skipped']);
317
- }
318
- emitPartial = emitResult.emitSkipped;
319
- for (const d of emitResult.diagnostics) {
320
- errors.push(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
321
- }
322
- for (const sourceFile of program.getSourceFiles()) {
323
- if (!sourceFile.isDeclarationFile)
324
- continue;
325
- const abs = path.resolve(sourceFile.fileName);
326
- const rel = path.relative(entryDir, abs).split(path.sep).join('/');
327
- if (rel.startsWith('..') || rel.includes('node_modules/'))
328
- continue;
329
- declarationSources.set(rel, sourceFile.getFullText());
330
- sourceByEmitted.set(rel, abs);
331
- }
300
+ });
301
+ written = emitDeclarations({
302
+ rootNames: [entryPath, ...augmentationSources],
303
+ options: emitOptions,
304
+ host: deno?.host(emitOptions),
305
+ staging,
306
+ entryDir,
307
+ });
332
308
  }
333
309
  catch (err) {
334
310
  return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
@@ -338,6 +314,9 @@ export function captureStub(opts) {
338
314
  guard.unlink(entryPath);
339
315
  scratch.guard.remove(staging);
340
316
  }
317
+ const { emitted, declarationSources, sourceByEmitted } = written;
318
+ const emitPartial = written.partial;
319
+ errors.push(...written.diagnostics);
341
320
  // ---- Partial-emit recovery ----
342
321
  // The corpus-2 notifications-svc shape: one file's declaration emit was
343
322
  // skipped but the rest of the tree emitted fine. Keep the tree; demote any
@@ -490,6 +469,7 @@ export function captureStub(opts) {
490
469
  target: parsed.options.target !== undefined ? ts.ScriptTarget[parsed.options.target] : undefined,
491
470
  }, null, 2) + '\n');
492
471
  // ---- Capture-time self-check (per-alias closure attribution) ----
472
+ progress('self-check', 'checking the stub');
493
473
  const aliases = selfCheckStub({
494
474
  guard: stubGuard,
495
475
  stubDir,
@@ -526,6 +506,58 @@ export function captureStub(opts) {
526
506
  errors,
527
507
  };
528
508
  }
509
+ /**
510
+ * Phase B: build the program of the final entry and emit its declarations.
511
+ *
512
+ * A function of its own so that its program dies with it (carrick#1916). The
513
+ * program is as large as the service, and nothing after the emit reads it:
514
+ * what the capture needs is the text returned here. While the emit ran inside
515
+ * `captureStub`, that one long function's frame went on holding the program
516
+ * for as long as the capture ran, so the self-check loaded its own program
517
+ * beside it; on a 2,345-file service that was 1,290 MB of a 2,741 MB peak.
518
+ *
519
+ * The caller builds the options and the host. A host is held by its program
520
+ * and holds none itself, so the caller keeping one keeps no program alive.
521
+ *
522
+ * Throws when the emit was skipped and wrote nothing.
523
+ */
524
+ function emitDeclarations(args) {
525
+ const emitted = new Map();
526
+ const declarationSources = new Map();
527
+ const sourceByEmitted = new Map();
528
+ const program = ts.createProgram(args.rootNames, sidecarCompilerOptions(args.options), args.host);
529
+ const emitResult = program.emit(undefined, (fileName, text, _bom, _error, sources) => {
530
+ emitted.set(fileName, text);
531
+ if (sources?.[0])
532
+ sourceByEmitted.set(path.relative(args.staging, fileName).split(path.sep).join('/'), sources[0].fileName);
533
+ }, undefined,
534
+ /* emitOnlyDtsFiles */ true);
535
+ // emitSkipped is PER-PROGRAM even when only one file's declaration emit
536
+ // failed (e.g. TS4023 from a hand-rolled ambient stub shadowing a real
537
+ // package): every other file's .d.ts was still written to the callback.
538
+ // Fail wholesale only when nothing at all emitted; otherwise keep the
539
+ // emitted subset and demote exactly the aliases it cannot support.
540
+ if (emitResult.emitSkipped && emitted.size === 0) {
541
+ throw new Error('declaration emit was skipped');
542
+ }
543
+ for (const sourceFile of program.getSourceFiles()) {
544
+ if (!sourceFile.isDeclarationFile)
545
+ continue;
546
+ const abs = path.resolve(sourceFile.fileName);
547
+ const rel = path.relative(args.entryDir, abs).split(path.sep).join('/');
548
+ if (rel.startsWith('..') || rel.includes('node_modules/'))
549
+ continue;
550
+ declarationSources.set(rel, sourceFile.getFullText());
551
+ sourceByEmitted.set(rel, abs);
552
+ }
553
+ return {
554
+ emitted,
555
+ declarationSources,
556
+ sourceByEmitted,
557
+ partial: emitResult.emitSkipped,
558
+ diagnostics: emitResult.diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')),
559
+ };
560
+ }
529
561
  /**
530
562
  * Partial-emit demotion: demote every anchor whose alias text names, by a
531
563
  * relative or an absolute path, a module the stub will not resolve, and
@@ -655,15 +687,21 @@ function resolveAnchors(opts, parsed, ctx, deno) {
655
687
  }
656
688
  ctx.guard.writeFile(ctx.entryPath, placeholderLines.join('\n') + '\n');
657
689
  try {
690
+ ctx.progress('program', 'building the program the anchors are read in');
658
691
  const anchorSources = [
659
692
  ...new Set(opts.anchors
660
693
  .flatMap((a) => (a.source_file ? [path.join(ctx.repoRoot, a.source_file)] : []))),
661
694
  ].filter((f) => fs.existsSync(f));
662
- const options = {
695
+ const options = sidecarCompilerOptions({
663
696
  ...parsed.options,
664
697
  noEmit: true,
665
- };
698
+ });
666
699
  const program = ts.createProgram([ctx.entryPath, ...anchorSources, ...(deno?.globals ?? [])], options, deno?.host(options));
700
+ // The checker binds every file of the program when it is first asked for,
701
+ // which the first anchor would otherwise do: asked for here, so that the
702
+ // report below says the program is whole before any anchor is read.
703
+ program.getTypeChecker();
704
+ ctx.progress('anchors', `0 of ${opts.anchors.length}`);
667
705
  const entrySource = program.getSourceFile(ctx.entryPath);
668
706
  const placeholders = new Map();
669
707
  if (entrySource) {
@@ -696,13 +734,17 @@ function resolveAnchors(opts, parsed, ctx, deno) {
696
734
  siblingSymbolSpecs.set(anchor.symbol_name, entryRelativeSpecifier(ctx.entryDir, ctx.repoRoot, anchor.source_file, resolveFromEntry));
697
735
  }
698
736
  }
699
- return opts.anchors.map((request) => resolveAnchor(program, request, {
700
- repoRoot: ctx.repoRoot,
701
- entryDir: ctx.entryDir,
702
- placeholder: placeholders.get(request.alias),
703
- siblingSymbolSpecs,
704
- resolveFromEntry,
705
- }));
737
+ return opts.anchors.map((request, index) => {
738
+ const anchor = resolveAnchor(program, request, {
739
+ repoRoot: ctx.repoRoot,
740
+ entryDir: ctx.entryDir,
741
+ placeholder: placeholders.get(request.alias),
742
+ siblingSymbolSpecs,
743
+ resolveFromEntry,
744
+ });
745
+ ctx.progress('anchors', `${index + 1} of ${opts.anchors.length}`);
746
+ return anchor;
747
+ });
706
748
  }
707
749
  finally {
708
750
  if (fs.existsSync(ctx.entryPath))
@@ -24,16 +24,17 @@
24
24
  import ts from 'typescript';
25
25
  import * as fs from 'node:fs';
26
26
  import * as path from 'node:path';
27
+ import { sidecarCompilerOptions } from './compiler-options.js';
27
28
  import { isPublishedSemver } from './lockfile.js';
28
29
  const DECLARATION_SUFFIXES = ['', '.d.ts', '.d.mts', '.d.cts', '.ts', '.mts', '.cts', '/index.d.ts'];
29
- const RESOLUTION_OPTIONS = {
30
+ const RESOLUTION_OPTIONS = sidecarCompilerOptions({
30
31
  module: ts.ModuleKind.ESNext,
31
32
  moduleResolution: ts.ModuleResolutionKind.Bundler,
32
33
  target: ts.ScriptTarget.ESNext,
33
34
  noEmit: true,
34
35
  skipLibCheck: true,
35
36
  types: [],
36
- };
37
+ });
37
38
  /** DefinitelyTyped packages are imported by the runtime name (TS6137). */
38
39
  function publicNameOf(name) {
39
40
  if (!name.startsWith('@types/'))
@@ -32,6 +32,7 @@
32
32
  import ts from 'typescript';
33
33
  import * as fs from 'node:fs';
34
34
  import * as path from 'node:path';
35
+ import { sidecarCompilerOptions } from './compiler-options.js';
35
36
  import { collectSpecifiers, declarationCandidates, isDeclarationFileName, isRelative, packageNameOf, } from './specifiers.js';
36
37
  import { repairDanglingImports } from './repair-dangling.js';
37
38
  import { findDisqualifyingTopTypes, findUnresolvedPlaceholders, isErrorPlaceholder, provenanceOf, unemittedModuleProvenance, unresolvedInTreeProvenance, } from './deep-walk.js';
@@ -78,7 +79,7 @@ export function selfCheckStub(args) {
78
79
  }
79
80
  }
80
81
  function runSelfCheck(args, treeFiles, repaired) {
81
- const options = {
82
+ const options = sidecarCompilerOptions({
82
83
  noEmit: true,
83
84
  strict: true,
84
85
  // MUST be false: the whole stub tree is .d.ts (see module header).
@@ -86,7 +87,7 @@ function runSelfCheck(args, treeFiles, repaired) {
86
87
  module: ts.ModuleKind.ESNext,
87
88
  moduleResolution: ts.ModuleResolutionKind.Bundler,
88
89
  types: [],
89
- };
90
+ });
90
91
  const program = ts.createProgram(treeFiles, options, args.compilerHost?.(options));
91
92
  const checker = program.getTypeChecker();
92
93
  const diagnostics = ts.getPreEmitDiagnostics(program);
@@ -26,11 +26,12 @@
26
26
  * inference path in `type-inferrer.ts`), which walks the resolved `Type` and
27
27
  * rebuilds the inlined text.
28
28
  *
29
- * Each resolve call builds its own throwaway in-memory project over the stub
30
- * tree, so the warm sidecar's long-lived project never sees stub files and
31
- * cannot accumulate stale trees across requests.
29
+ * Each resolve call builds its own throwaway project over the stub tree and
30
+ * reads nothing else: not the service's project, and not whether the sidecar
31
+ * was ever initialised. So the request costs what the stub costs, in a process
32
+ * that has built nothing as in one that has (carrick#1927), and the sidecar's
33
+ * long-lived project never sees stub files.
32
34
  */
33
- import { Project } from 'ts-morph';
34
35
  export interface ResolvedDefinition {
35
36
  type_alias: string;
36
37
  /** Original declaration text as written (preserves named types) */
@@ -39,10 +40,6 @@ export interface ResolvedDefinition {
39
40
  expanded: string;
40
41
  }
41
42
  export declare class DefinitionResolver {
42
- private readonly project;
43
- constructor(options: {
44
- project: Project;
45
- });
46
43
  /**
47
44
  * Resolve surface aliases from a capture stub package directory
48
45
  * (`<stub_dir>/types/surface.d.ts` + its declaration tree).
@@ -26,19 +26,18 @@
26
26
  * inference path in `type-inferrer.ts`), which walks the resolved `Type` and
27
27
  * rebuilds the inlined text.
28
28
  *
29
- * Each resolve call builds its own throwaway in-memory project over the stub
30
- * tree, so the warm sidecar's long-lived project never sees stub files and
31
- * cannot accumulate stale trees across requests.
29
+ * Each resolve call builds its own throwaway project over the stub tree and
30
+ * reads nothing else: not the service's project, and not whether the sidecar
31
+ * was ever initialised. So the request costs what the stub costs, in a process
32
+ * that has built nothing as in one that has (carrick#1927), and the sidecar's
33
+ * long-lived project never sees stub files.
32
34
  */
33
35
  import * as path from 'node:path';
34
36
  import * as fs from 'node:fs';
35
37
  import { Project, Node } from 'ts-morph';
38
+ import { sidecarCompilerOptions } from './capture/index.js';
36
39
  import { expandTypeStructural, } from './type-structural-expander.js';
37
40
  export class DefinitionResolver {
38
- project;
39
- constructor(options) {
40
- this.project = options.project;
41
- }
42
41
  /**
43
42
  * Resolve surface aliases from a capture stub package directory
44
43
  * (`<stub_dir>/types/surface.d.ts` + its declaration tree).
@@ -59,13 +58,13 @@ export class DefinitionResolver {
59
58
  }
60
59
  try {
61
60
  const stubProject = new Project({
62
- compilerOptions: {
61
+ compilerOptions: sidecarCompilerOptions({
63
62
  target: 99, // ESNext
64
63
  module: 99, // ESNext
65
64
  moduleResolution: 100, // Bundler
66
65
  strict: true,
67
66
  skipLibCheck: true,
68
- },
67
+ }),
69
68
  skipAddingFilesFromTsConfig: true,
70
69
  });
71
70
  for (const filePath of walkDtsFiles(typesDir)) {
@@ -27,6 +27,8 @@ import { captureStub, findDisqualifyingTopTypes, jsonWireDeclarations, runCheck,
27
27
  import { Retyper } from './retype.js';
28
28
  import { LibraryClaimsVerifier, httpCheck } from './library-claims.js';
29
29
  import { PROGRESS_INTERVAL_MS, atMostEvery } from './progress.js';
30
+ import { mergeInferTimings } from './infer-timing.js';
31
+ import { addProgramFiles, listProgramFiles } from './program-files.js';
30
32
  // ===========================================================================
31
33
  // Module-level state
32
34
  // ===========================================================================
@@ -38,6 +40,20 @@ let initTimeMs = null;
38
40
  * the service's files (carrick#1604).
39
41
  */
40
42
  let components = new Map();
43
+ /**
44
+ * Reads a capture stub's own declaration tree and nothing of the init'd
45
+ * project, so it is not one of the project's components: taking it from them
46
+ * would build the service's whole program to answer about the stub
47
+ * (carrick#1927).
48
+ */
49
+ const definitionResolver = new DefinitionResolver();
50
+ /** The init'd project loader, or the error every project-backed action gives without one. */
51
+ function initializedLoader() {
52
+ if (!projectLoader?.isInitialized()) {
53
+ throw new Error('Sidecar not initialized. Call init first.');
54
+ }
55
+ return projectLoader;
56
+ }
41
57
  /**
42
58
  * Get the project-backed components, building the project if this is the
43
59
  * first request that needs it.
@@ -45,15 +61,12 @@ let components = new Map();
45
61
  * @throws if init has not run, or if the project cannot be built
46
62
  */
47
63
  function projectComponents(key = '') {
48
- if (!projectLoader?.isInitialized()) {
49
- throw new Error('Sidecar not initialized. Call init first.');
50
- }
64
+ // Bound here rather than read from the module slot inside the components:
65
+ // a re-init drops `components` and points the slot at another service, and
66
+ // nothing built over this project may follow it there.
67
+ const loader = initializedLoader();
51
68
  let built = components.get(key);
52
69
  if (!built) {
53
- // Bound here rather than read from the module slot inside the components:
54
- // a re-init drops `components` and points the slot at another service, and
55
- // nothing built over this project may follow it there.
56
- const loader = projectLoader;
57
70
  const project = loader.getProjectFor(key);
58
71
  const repoRoot = loader.getRepoRoot();
59
72
  // The module graph, where the project resolved through one, is the only
@@ -67,7 +80,6 @@ function projectComponents(key = '') {
67
80
  built = {
68
81
  typeBundler: new TypeBundler({ project, repoRoot }),
69
82
  typeInferrer,
70
- definitionResolver: new DefinitionResolver({ project }),
71
83
  // Locates calls exactly as `infer` does, so it rewrites the node the
72
84
  // consumer's published type came from (carrick#1491).
73
85
  // The walk is typed against the capture bundle's compiler copy and
@@ -218,6 +230,10 @@ function handleBundle(request) {
218
230
  function handleCaptureV2(request) {
219
231
  try {
220
232
  log(`capture_v2 for service '${request.service_name}' (${request.anchors.length} anchor(s))`);
233
+ // A frame for each stage as the capture reaches it, and within a stage
234
+ // (one report per anchor) at most one per interval.
235
+ let stage;
236
+ const withinStage = atMostEvery(PROGRESS_INTERVAL_MS, (phase, message) => writeProgress(request.request_id, phase, message));
221
237
  const result = captureStub({
222
238
  repoRoot: request.repo_root,
223
239
  serviceName: request.service_name,
@@ -225,6 +241,12 @@ function handleCaptureV2(request) {
225
241
  outDir: request.out_dir,
226
242
  tsconfigPath: request.tsconfig_path,
227
243
  scanRoot: request.scan_root,
244
+ onProgress: (phase, message) => {
245
+ if (phase === stage)
246
+ return withinStage(phase, message);
247
+ stage = phase;
248
+ writeProgress(request.request_id, phase, message);
249
+ },
228
250
  });
229
251
  return {
230
252
  request_id: request.request_id,
@@ -316,6 +338,9 @@ function handleInfer(request) {
316
338
  request_id: request.request_id,
317
339
  status: result.success ? 'success' : 'error',
318
340
  inferred_types: result.inferred_types,
341
+ // Beside the answers and never in them: what the requests cost, for
342
+ // the caller's log (carrick#1985).
343
+ infer_timing: mergeInferTimings(results.map((r) => r.timing)),
319
344
  errors: result.errors,
320
345
  };
321
346
  }
@@ -345,11 +370,19 @@ function handleRetypeCheck(request) {
345
370
  const groups = byProject(request.items.map((item, index) => ({ item, index })), ({ item }) => item.file_path);
346
371
  const deadline = performance.now() + budget;
347
372
  const outcomes = new Array(request.items.length);
373
+ // One count for the request, whichever programs its items span, reported
374
+ // as `infer` reports its batch (carrick#1914, carrick#1945).
375
+ let judged = 0;
376
+ const report = atMostEvery(PROGRESS_INTERVAL_MS, () => writeProgress(request.request_id, 'retype', `${judged} of ${request.items.length}`));
377
+ const onJudged = () => {
378
+ judged += 1;
379
+ report();
380
+ };
348
381
  for (const [key, entries] of groups) {
349
382
  // One budget for the request, whichever programs it spans.
350
383
  const remaining = groups.size === 1 ? budget : Math.max(0, deadline - performance.now());
351
384
  projectComponents(key)
352
- .retyper.run(entries.map(({ item }) => item), remaining)
385
+ .retyper.run(entries.map(({ item }) => item), remaining, onJudged)
353
386
  .forEach((outcome, position) => {
354
387
  outcomes[entries[position].index] = outcome;
355
388
  });
@@ -446,12 +479,13 @@ function handleListLibrarySurface(request) {
446
479
  }
447
480
  /**
448
481
  * Handle the 'resolve_definitions' action - resolve surface aliases from a
449
- * v2 capture stub package's declaration tree.
482
+ * v2 capture stub package's declaration tree. Stateless, as `capture_v2` is:
483
+ * it needs no init and builds no project but the stub's own.
450
484
  */
451
485
  function handleResolveDefinitions(request) {
452
486
  try {
453
487
  log(`Resolving ${request.aliases.length} type alias(es) from ${request.stub_dir}`);
454
- const results = projectComponents().definitionResolver.resolveFromStub(request.stub_dir, request.aliases);
488
+ const results = definitionResolver.resolveFromStub(request.stub_dir, request.aliases);
455
489
  return {
456
490
  request_id: request.request_id,
457
491
  status: 'success',
@@ -472,6 +506,55 @@ function handleResolveDefinitions(request) {
472
506
  };
473
507
  }
474
508
  }
509
+ /**
510
+ * Handle the 'list_program_files' action - the root files the default
511
+ * project's program was built from, in order (carrick#2027). It is the only
512
+ * program a request grows: a file a referenced project owns is in that
513
+ * project's program from its tsconfig, so a program built for an owning
514
+ * project (carrick#1604) holds the same files in every process. A process
515
+ * that has not built the default project lists nothing, and does not build
516
+ * it to answer.
517
+ */
518
+ function handleListProgramFiles(request) {
519
+ try {
520
+ const project = initializedLoader().builtProject();
521
+ const files = project ? listProgramFiles(project) : [];
522
+ log(`Listing ${files.length} program file(s)`);
523
+ return { request_id: request.request_id, status: 'success', files };
524
+ }
525
+ catch (err) {
526
+ const error = err instanceof Error ? err.message : String(err);
527
+ logError(`Listing program files failed: ${error}`);
528
+ return { request_id: request.request_id, status: 'error', errors: [error] };
529
+ }
530
+ }
531
+ /**
532
+ * Handle the 'add_program_files' action - add the given files to the default
533
+ * project's program in the given order, rebuilding it at most once
534
+ * (carrick#2027). A file a referenced project owns is skipped, as an `infer`
535
+ * on it would never add it to this program: its owner's program already holds
536
+ * it.
537
+ */
538
+ function handleAddProgramFiles(request) {
539
+ try {
540
+ const loader = initializedLoader();
541
+ const startTime = performance.now();
542
+ const files = request.files
543
+ .map((file) => path.resolve(loader.getRepoRoot(), file))
544
+ .filter((file) => loader.projectKeyFor(file) === '');
545
+ const added = files.length === 0
546
+ ? 0
547
+ : addProgramFiles(loader.getProject(), files, (file, reason) => logError(`Not added to the program: ${file}: ${reason}`));
548
+ log(`Added ${added} of ${request.files.length} file(s) to the program in ` +
549
+ `${Math.round(performance.now() - startTime)}ms`);
550
+ return { request_id: request.request_id, status: 'success', added };
551
+ }
552
+ catch (err) {
553
+ const error = err instanceof Error ? err.message : String(err);
554
+ logError(`Adding program files failed: ${error}`);
555
+ return { request_id: request.request_id, status: 'error', errors: [error] };
556
+ }
557
+ }
475
558
  /**
476
559
  * Handle the 'health' action - report initialization status
477
560
  */
@@ -524,6 +607,10 @@ function handleRequest(request) {
524
607
  return handleVerifyLibraryClaims(request);
525
608
  case 'list_library_surface':
526
609
  return handleListLibrarySurface(request);
610
+ case 'list_program_files':
611
+ return handleListProgramFiles(request);
612
+ case 'add_program_files':
613
+ return handleAddProgramFiles(request);
527
614
  case 'health':
528
615
  return handleHealth(request);
529
616
  case 'shutdown':
@@ -619,6 +706,31 @@ function processLine(line) {
619
706
  const response = handleRequest(parseResult.request);
620
707
  writeResponse(response);
621
708
  }
709
+ /** How often the sidecar looks for the process that started it. */
710
+ const PARENT_CHECK_MS = 1000;
711
+ /**
712
+ * Exit once the process that started this one is gone (carrick#2029).
713
+ *
714
+ * Its death closes stdin, which ends this process, unless another process
715
+ * still holds the write end. Then the only sign is that this process has a
716
+ * new parent: whatever adopts it (init, or a subreaper on Linux) has a pid of
717
+ * its own. So the parent at startup is kept and compared, never 1. A process
718
+ * already adopted at startup has no parent to watch, and stdin alone decides.
719
+ *
720
+ * The timer is unref'd: it never keeps an otherwise finished process alive.
721
+ * A handler that blocks the event loop holds it off until it returns.
722
+ */
723
+ function exitWhenParentIsGone() {
724
+ const startedBy = process.ppid;
725
+ if (startedBy <= 1)
726
+ return;
727
+ setInterval(() => {
728
+ if (process.ppid !== startedBy) {
729
+ log(`parent ${startedBy} is gone, exiting`);
730
+ process.exit(0);
731
+ }
732
+ }, PARENT_CHECK_MS).unref();
733
+ }
622
734
  /**
623
735
  * Start the message loop
624
736
  */
@@ -634,6 +746,7 @@ function main() {
634
746
  log('stdin closed, exiting');
635
747
  process.exit(0);
636
748
  });
749
+ exitWhenParentIsGone();
637
750
  // Handle process signals
638
751
  process.on('SIGINT', () => {
639
752
  log('SIGINT received, exiting');
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Which requests of an `infer` batch were slow, and at what (carrick#1985).
3
+ *
4
+ * A pass can send thousands of requests and spend most of its time in a few
5
+ * of them. The inferrer times every request; this module turns those timings
6
+ * into what the batch's answer carries: how many there were and what they
7
+ * add up to, the slowest few by name, the request that printed the longest
8
+ * type, and the requests that were the first asked of their file, apart.
9
+ *
10
+ * A request's time is also told apart by what it went on: the compiler call
11
+ * that computes the type, and the print of that type to text. The two have
12
+ * different remedies, and a printed length alone cannot say which one a slow
13
+ * request was: a type of four characters can take seconds to compute, and a
14
+ * type of a million can take half a second to print.
15
+ *
16
+ * It measures and names. It changes no answer and holds no type text.
17
+ */
18
+ import type { InferSlotTiming, InferTiming } from './types.js';
19
+ /**
20
+ * How many of its slowest requests a batch's answer names.
21
+ *
22
+ * Enough for a reader adding batches up to say what the slowest hundredth of
23
+ * a whole pass took: a pass's slowest requests are among its batches'
24
+ * slowest, and the last one a batch names is the most any it left out can
25
+ * have taken.
26
+ */
27
+ export declare const SLOWEST_SLOTS = 25;
28
+ /** How long something that started at `startedMs` has taken by `nowMs`. */
29
+ export declare function elapsedMs(startedMs: number, nowMs: number): number;
30
+ /**
31
+ * The parts of a request's time that are told apart: `type` is the compiler
32
+ * call that computes a type, `print` is the print of a type to text.
33
+ */
34
+ export type TimedPhase = 'type' | 'print';
35
+ /**
36
+ * Run `work`, and count how long it took as `phase`.
37
+ *
38
+ * The count is the process's, not a request's: whoever wants one request's
39
+ * share reads `phaseClock` before the request and after it. One request runs
40
+ * at a time, so the difference is that request's and nobody else's.
41
+ */
42
+ export declare function timedPhase<T>(phase: TimedPhase, work: () => T): T;
43
+ /** What each phase has come to so far, in milliseconds. */
44
+ export declare function phaseClock(): Readonly<Record<TimedPhase, number>>;
45
+ /**
46
+ * What a batch's answer says of the time its requests took. `slots` holds one
47
+ * timing per request, in the order the batch was done with them.
48
+ */
49
+ export declare function inferTiming(slots: readonly InferSlotTiming[]): InferTiming;
50
+ /**
51
+ * One timing for a request several projects answered, each with its own:
52
+ * the counts and the times added up, and the slowest of all of them.
53
+ */
54
+ export declare function mergeInferTimings(parts: readonly InferTiming[]): InferTiming;