carrick 0.3.102 → 0.3.104

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 (45) hide show
  1. package/package.json +6 -6
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/sidecar/dist/src/bundler.d.ts +3 -92
  4. package/sidecar/dist/src/bundler.js +5 -265
  5. package/sidecar/dist/src/capture/anchors.js +113 -8
  6. package/sidecar/dist/src/capture/api.d.ts +6 -0
  7. package/sidecar/dist/src/capture/check-classify.js +27 -0
  8. package/sidecar/dist/src/capture/check-fields.js +19 -5
  9. package/sidecar/dist/src/capture/check-probe.d.ts +6 -1
  10. package/sidecar/dist/src/capture/check-probe.js +45 -2
  11. package/sidecar/dist/src/capture/check-workspace.d.ts +3 -0
  12. package/sidecar/dist/src/capture/check-workspace.js +13 -16
  13. package/sidecar/dist/src/capture/check.js +5 -5
  14. package/sidecar/dist/src/capture/deep-walk.js +45 -12
  15. package/sidecar/dist/src/capture/deno-project.d.ts +2 -1
  16. package/sidecar/dist/src/capture/deno-project.js +22 -18
  17. package/sidecar/dist/src/capture/guarded-fs.d.ts +76 -0
  18. package/sidecar/dist/src/capture/guarded-fs.js +182 -0
  19. package/sidecar/dist/src/capture/index.d.ts +1 -0
  20. package/sidecar/dist/src/capture/index.js +65 -27
  21. package/sidecar/dist/src/capture/outside-root.d.ts +41 -0
  22. package/sidecar/dist/src/capture/outside-root.js +101 -0
  23. package/sidecar/dist/src/capture/paths-rewrite.d.ts +8 -0
  24. package/sidecar/dist/src/capture/paths-rewrite.js +9 -7
  25. package/sidecar/dist/src/capture/repair-dangling.d.ts +11 -1
  26. package/sidecar/dist/src/capture/repair-dangling.js +15 -4
  27. package/sidecar/dist/src/capture/self-check.d.ts +3 -0
  28. package/sidecar/dist/src/capture/self-check.js +3 -3
  29. package/sidecar/dist/src/capture/service-config.d.ts +19 -0
  30. package/sidecar/dist/src/capture/service-config.js +57 -0
  31. package/sidecar/dist/src/index.d.ts +1 -1
  32. package/sidecar/dist/src/index.js +4 -115
  33. package/sidecar/dist/src/origin.d.ts +49 -8
  34. package/sidecar/dist/src/origin.js +81 -8
  35. package/sidecar/dist/src/project-loader.d.ts +10 -2
  36. package/sidecar/dist/src/project-loader.js +22 -21
  37. package/sidecar/dist/src/type-inferrer.d.ts +72 -0
  38. package/sidecar/dist/src/type-inferrer.js +248 -8
  39. package/sidecar/dist/src/type-structural-expander.d.ts +5 -1
  40. package/sidecar/dist/src/type-structural-expander.js +1 -1
  41. package/sidecar/dist/src/types.d.ts +39 -178
  42. package/sidecar/dist/src/validators.d.ts +70 -914
  43. package/sidecar/dist/src/validators.js +2 -55
  44. package/sidecar/dist/src/monorepo-builder.d.ts +0 -129
  45. package/sidecar/dist/src/monorepo-builder.js +0 -584
@@ -37,17 +37,28 @@ import * as fs from 'node:fs';
37
37
  * `failing` maps an absolute file path to the specifiers the stub could not
38
38
  * resolve from it. Returns the files actually rewritten; a file whose names
39
39
  * cannot all be replaced by `unknown` is left exactly as it was.
40
+ *
41
+ * Only a file `tree` (a guard over the stub's types tree) lets it write is
42
+ * repaired (carrick#1742). The self-check program also loads files the stub
43
+ * merely reaches: the repo's own sources, through a workspace package linked
44
+ * into the `node_modules` the stub borrows, or a runtime's dependency cache.
45
+ * Those report their missing modules exactly as an emitted declaration does,
46
+ * and they belong to the user: a scan reads them and never writes them. The
47
+ * guard decides on the resolved path, so a file reached through the link is
48
+ * the repo's, whatever path the program loaded it by.
40
49
  */
41
- export function repairDanglingImports(failing) {
50
+ export function repairDanglingImports(failing, tree) {
42
51
  const repaired = new Map();
43
52
  for (const [file, specifiers] of failing) {
44
- const result = repairFile(file, specifiers);
53
+ if (!tree.allowsWrite(file))
54
+ continue;
55
+ const result = repairFile(file, specifiers, tree);
45
56
  if (result)
46
57
  repaired.set(file, result);
47
58
  }
48
59
  return repaired;
49
60
  }
50
- function repairFile(file, specifiers) {
61
+ function repairFile(file, specifiers, tree) {
51
62
  let text;
52
63
  try {
53
64
  text = fs.readFileSync(file, 'utf8');
@@ -116,7 +127,7 @@ function repairFile(file, specifiers) {
116
127
  for (const edit of edits) {
117
128
  out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
118
129
  }
119
- fs.writeFileSync(file, out);
130
+ tree.writeFile(file, out);
120
131
  return { specifiers: [...removedSpecifiers].sort(), names: [...names].sort() };
121
132
  }
122
133
  /** The module specifier an import STATEMENT names, if it names one. */
@@ -32,7 +32,10 @@
32
32
  import ts from 'typescript';
33
33
  import type { CaptureAliasRecord } from './api.js';
34
34
  import type { ResolvedAnchor } from './anchors.js';
35
+ import type { WriteGuard } from './guarded-fs.js';
35
36
  export interface SelfCheckArgs {
37
+ /** Writes are held to the stub (carrick#1748). */
38
+ guard: WriteGuard;
36
39
  stubDir: string;
37
40
  surfaceAbsPath: string;
38
41
  resolved: ResolvedAnchor[];
@@ -53,7 +53,7 @@ export function selfCheckStub(args) {
53
53
  const linkPath = path.join(args.stubDir, 'node_modules');
54
54
  let linked = false;
55
55
  if (!args.bareCheckout && fs.existsSync(repoNodeModules) && !fs.existsSync(linkPath)) {
56
- fs.symlinkSync(repoNodeModules, linkPath, 'dir');
56
+ args.guard.symlink(repoNodeModules, linkPath, 'dir');
57
57
  linked = true;
58
58
  }
59
59
  try {
@@ -65,7 +65,7 @@ export function selfCheckStub(args) {
65
65
  // the verdict: it reads the repaired text, and a name the rewrite did not
66
66
  // reach comes back as a `Cannot find name` diagnostic that
67
67
  // `repairedNameFailures` folds back into the same dangling specifier.
68
- const repaired = repairDanglingImports(first.internalFailuresByFile);
68
+ const repaired = repairDanglingImports(first.internalFailuresByFile, args.guard.narrow(typesDir));
69
69
  if (repaired.size === 0)
70
70
  return first.records;
71
71
  return runSelfCheck(args, treeFiles, repaired).records;
@@ -74,7 +74,7 @@ export function selfCheckStub(args) {
74
74
  // unlinkSync, not rmSync: the link target is a directory and rmSync
75
75
  // refuses symlinks-to-directories with EISDIR.
76
76
  if (linked)
77
- fs.unlinkSync(linkPath);
77
+ args.guard.unlink(linkPath);
78
78
  }
79
79
  }
80
80
  function runSelfCheck(args, treeFiles, repaired) {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Which tsconfig types a service that names none (carrick#1776).
3
+ *
4
+ * Both readers of a service, the init'd project and capture, ask this one
5
+ * function, so they type the service under the same options.
6
+ *
7
+ * The service root is searched first, for the names a service keeps its own
8
+ * config under. Above it, the search is `tsc`'s own when it is run with no
9
+ * `-p`: the nearest `tsconfig.json` in an ancestor directory. A layout that
10
+ * keeps one config above several services (their module resolution, `paths`,
11
+ * `lib`) is typed under that config, as the repo's own `tsc` types it; without
12
+ * it, every import only that config resolves read `any`.
13
+ *
14
+ * The walk stops at the scan root, inclusive: a config outside the scanned
15
+ * tree is never read. With no scan root, or a service outside it, only the
16
+ * service root is searched.
17
+ */
18
+ /** Absolute path of the config that types the service, or undefined for none. */
19
+ export declare function findServiceTsconfig(serviceRoot: string, scanRoot?: string): string | undefined;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Which tsconfig types a service that names none (carrick#1776).
3
+ *
4
+ * Both readers of a service, the init'd project and capture, ask this one
5
+ * function, so they type the service under the same options.
6
+ *
7
+ * The service root is searched first, for the names a service keeps its own
8
+ * config under. Above it, the search is `tsc`'s own when it is run with no
9
+ * `-p`: the nearest `tsconfig.json` in an ancestor directory. A layout that
10
+ * keeps one config above several services (their module resolution, `paths`,
11
+ * `lib`) is typed under that config, as the repo's own `tsc` types it; without
12
+ * it, every import only that config resolves read `any`.
13
+ *
14
+ * The walk stops at the scan root, inclusive: a config outside the scanned
15
+ * tree is never read. With no scan root, or a service outside it, only the
16
+ * service root is searched.
17
+ */
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ /** The names a service root is searched for, in order. */
21
+ const SERVICE_ROOT_NAMES = ['tsconfig.json', 'tsconfig.build.json', 'tsconfig.app.json'];
22
+ /** The name an ancestor directory is searched for, as `tsc` searches. */
23
+ const ANCESTOR_NAME = 'tsconfig.json';
24
+ /** Absolute path of the config that types the service, or undefined for none. */
25
+ export function findServiceTsconfig(serviceRoot, scanRoot) {
26
+ const root = path.resolve(serviceRoot);
27
+ for (const name of SERVICE_ROOT_NAMES) {
28
+ const candidate = path.join(root, name);
29
+ if (fs.existsSync(candidate))
30
+ return candidate;
31
+ }
32
+ if (scanRoot === undefined)
33
+ return undefined;
34
+ // Containment is decided on real paths, so a symlink on either end cannot
35
+ // hide the service inside the scan root or place it outside; the walk then
36
+ // climbs the path as given, one directory per level between them.
37
+ const fromBound = path.relative(realPath(scanRoot), realPath(root));
38
+ if (fromBound === '' || fromBound.startsWith('..') || path.isAbsolute(fromBound))
39
+ return undefined;
40
+ let dir = root;
41
+ for (let level = fromBound.split(path.sep).length; level > 0; level--) {
42
+ dir = path.dirname(dir);
43
+ const candidate = path.join(dir, ANCESTOR_NAME);
44
+ if (fs.existsSync(candidate))
45
+ return candidate;
46
+ }
47
+ return undefined;
48
+ }
49
+ /** The path with every symlink resolved; the path as given when it does not exist. */
50
+ function realPath(p) {
51
+ try {
52
+ return fs.realpathSync(p);
53
+ }
54
+ catch {
55
+ return path.resolve(p);
56
+ }
57
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * This module implements a message loop that:
5
5
  * 1. Listens on stdin for JSON requests
6
- * 2. Processes each request (init, bundle, emit_surface, infer, build_workspace, check_compatibility, health, shutdown)
6
+ * 2. Processes each request (see handleRequest for the actions)
7
7
  * 3. Writes JSON responses to stdout
8
8
  *
9
9
  * IMPORTANT:
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * This module implements a message loop that:
5
5
  * 1. Listens on stdin for JSON requests
6
- * 2. Processes each request (init, bundle, emit_surface, infer, build_workspace, check_compatibility, health, shutdown)
6
+ * 2. Processes each request (see handleRequest for the actions)
7
7
  * 3. Writes JSON responses to stdout
8
8
  *
9
9
  * IMPORTANT:
@@ -15,9 +15,8 @@ import * as path from 'node:path';
15
15
  import * as readline from 'node:readline';
16
16
  import { parseRequest } from './validators.js';
17
17
  import { ProjectLoader } from './project-loader.js';
18
- import { TypeBundler, SurfaceEmitter } from './bundler.js';
18
+ import { TypeBundler } from './bundler.js';
19
19
  import { TypeInferrer } from './type-inferrer.js';
20
- import { MonorepoBuilder } from './monorepo-builder.js';
21
20
  import { DefinitionResolver } from './definition-resolver.js';
22
21
  import { captureStub, findDisqualifyingTopTypes, jsonWireDeclarations, runCheck, } from './capture/index.js';
23
22
  import { Retyper } from './retype.js';
@@ -26,7 +25,6 @@ import { LibraryClaimsVerifier, httpCheck } from './library-claims.js';
26
25
  // Module-level state
27
26
  // ===========================================================================
28
27
  let projectLoader = null;
29
- let monorepoBuilder = null;
30
28
  let initTimeMs = null;
31
29
  /**
32
30
  * Components by project key (`ProjectLoader.projectKeyFor`): `''` is the
@@ -62,7 +60,6 @@ function projectComponents(key = '') {
62
60
  });
63
61
  built = {
64
62
  typeBundler: new TypeBundler({ project, repoRoot }),
65
- surfaceEmitter: new SurfaceEmitter({ project, repoRoot }),
66
63
  typeInferrer,
67
64
  definitionResolver: new DefinitionResolver({ project }),
68
65
  // Locates calls exactly as `infer` does, so it rewrites the node the
@@ -128,6 +125,7 @@ function handleInit(request) {
128
125
  projectLoader = new ProjectLoader({
129
126
  repoRoot: request.repo_root,
130
127
  tsconfigPath: request.tsconfig_path,
128
+ scanRoot: request.scan_root,
131
129
  tsconfigSnapshot: request.tsconfig_snapshot,
132
130
  pinnedDependencies: request.pinned_dependencies,
133
131
  });
@@ -143,8 +141,6 @@ function handleInit(request) {
143
141
  // The project and everything that reads it are built by the first request
144
142
  // that needs them, so readiness costs the same on a bare checkout as on
145
143
  // one with its dependencies installed (carrick#749).
146
- // Initialize monorepo builder (doesn't need project)
147
- monorepoBuilder = new MonorepoBuilder();
148
144
  initTimeMs = result.initTimeMs || Math.round(performance.now() - startTime);
149
145
  log(`Initialization complete in ${initTimeMs}ms`);
150
146
  return {
@@ -202,38 +198,6 @@ function handleBundle(request) {
202
198
  };
203
199
  }
204
200
  }
205
- /**
206
- * Handle the 'emit_surface' action - emit a surface .d.ts with rewritten specifiers
207
- */
208
- function handleEmitSurface(request) {
209
- try {
210
- log(`Emitting surface for repo '${request.repo_name}' with ${request.payloads.length} payload(s)`);
211
- const result = projectComponents().surfaceEmitter.emit(request.repo_name, request.payloads, request.output_path);
212
- if (!result.success) {
213
- return {
214
- request_id: request.request_id,
215
- status: 'error',
216
- errors: result.errors,
217
- };
218
- }
219
- return {
220
- request_id: request.request_id,
221
- status: 'success',
222
- output_path: result.output_path,
223
- surface_content: result.surface_content,
224
- manifest: result.manifest,
225
- };
226
- }
227
- catch (err) {
228
- const error = err instanceof Error ? err.message : String(err);
229
- logError(`Surface emission failed: ${error}`);
230
- return {
231
- request_id: request.request_id,
232
- status: 'error',
233
- errors: [error],
234
- };
235
- }
236
- }
237
201
  /**
238
202
  * Handle the 'capture_v2' action - v2 "tsc as serializer" capture.
239
203
  * Stateless by design: unlike bundle/infer it needs no init'd ts-morph
@@ -250,6 +214,7 @@ function handleCaptureV2(request) {
250
214
  anchors: request.anchors,
251
215
  outDir: request.out_dir,
252
216
  tsconfigPath: request.tsconfig_path,
217
+ scanRoot: request.scan_root,
253
218
  });
254
219
  return {
255
220
  request_id: request.request_id,
@@ -461,76 +426,6 @@ function handleListLibrarySurface(request) {
461
426
  return { request_id: request.request_id, status: 'error', errors: [error] };
462
427
  }
463
428
  }
464
- /**
465
- * Handle the 'build_workspace' action - build synthetic monorepo workspace
466
- */
467
- function handleBuildWorkspace(request) {
468
- // MonorepoBuilder doesn't require project initialization
469
- if (!monorepoBuilder) {
470
- monorepoBuilder = new MonorepoBuilder();
471
- }
472
- try {
473
- log(`Building synthetic workspace with ${request.repos.length} repo(s)`);
474
- const result = monorepoBuilder.build(request.repos, request.workspace_root);
475
- if (!result.success) {
476
- return {
477
- request_id: request.request_id,
478
- status: 'error',
479
- errors: result.errors,
480
- };
481
- }
482
- return {
483
- request_id: request.request_id,
484
- status: 'success',
485
- workspace_path: result.workspace_path,
486
- stub_packages: result.stub_packages,
487
- checker_path: result.checker_path,
488
- };
489
- }
490
- catch (err) {
491
- const error = err instanceof Error ? err.message : String(err);
492
- logError(`Workspace build failed: ${error}`);
493
- return {
494
- request_id: request.request_id,
495
- status: 'error',
496
- errors: [error],
497
- };
498
- }
499
- }
500
- /**
501
- * Handle the 'check_compatibility' action - run type compatibility checks
502
- */
503
- function handleCheckCompatibility(request) {
504
- if (!monorepoBuilder) {
505
- monorepoBuilder = new MonorepoBuilder();
506
- }
507
- try {
508
- log(`Running compatibility checks: ${request.checks.length} check(s)`);
509
- const result = monorepoBuilder.checkCompatibility(request.workspace_root, request.checks);
510
- if (!result.success) {
511
- return {
512
- request_id: request.request_id,
513
- status: 'error',
514
- errors: result.errors,
515
- };
516
- }
517
- return {
518
- request_id: request.request_id,
519
- status: 'success',
520
- results: result.results,
521
- diagnostics: result.diagnostics,
522
- };
523
- }
524
- catch (err) {
525
- const error = err instanceof Error ? err.message : String(err);
526
- logError(`Compatibility check failed: ${error}`);
527
- return {
528
- request_id: request.request_id,
529
- status: 'error',
530
- errors: [error],
531
- };
532
- }
533
- }
534
429
  /**
535
430
  * Handle the 'resolve_definitions' action - resolve surface aliases from a
536
431
  * v2 capture stub package's declaration tree.
@@ -597,16 +492,10 @@ function handleRequest(request) {
597
492
  return handleInit(request);
598
493
  case 'bundle':
599
494
  return handleBundle(request);
600
- case 'emit_surface':
601
- return handleEmitSurface(request);
602
495
  case 'capture_v2':
603
496
  return handleCaptureV2(request);
604
497
  case 'infer':
605
498
  return handleInfer(request);
606
- case 'build_workspace':
607
- return handleBuildWorkspace(request);
608
- case 'check_compatibility':
609
- return handleCheckCompatibility(request);
610
499
  case 'resolve_definitions':
611
500
  return handleResolveDefinitions(request);
612
501
  case 'retype_check':
@@ -13,7 +13,7 @@
13
13
  * bundle, so the two are kept in lockstep by `machinery-indicator-mirror.test.ts`
14
14
  * rather than by sharing this module.
15
15
  */
16
- import type { ts } from 'ts-morph';
16
+ import type { Project, ResolutionHostFactory, ts } from 'ts-morph';
17
17
  /**
18
18
  * True when a declaration's source file is runtime/library origin rather than
19
19
  * user source. Four answers, in order:
@@ -32,14 +32,55 @@ import type { ts } from 'ts-morph';
32
32
  * types from its own cache, a path with no `node_modules` segment, and
33
33
  * `DenoProject.resolve` hands the compiler `isExternalLibraryImport` from
34
34
  * the graph, which the compiler records on the file. Nothing crosses the
35
- * capture seam; both programs are built with that host. One exclusion: a
36
- * workspace package reached through a `node_modules` symlink is also
37
- * marked external by the compiler but is the user's own source, so a file
38
- * inside the checkout (the nearest `.git` above the service root) that
39
- * carries no `node_modules` segment stays user source.
35
+ * capture seam; both programs are built with that host. The compiler's
36
+ * record does not survive an edit to a ts-morph project, so `imports`, the
37
+ * resolver's answers kept for the project's life, is read beside it
38
+ * (`ExternalImports`, carrick#1731). One exclusion: a workspace package
39
+ * reached through a `node_modules` symlink is also marked external by the
40
+ * compiler but is the user's own source, so a file inside the checkout
41
+ * (the nearest `.git` above the service root) that carries no
42
+ * `node_modules` segment stays user source.
40
43
  *
41
44
  * Lockstep mirror of `isExternalOrigin` in `capture/machinery.ts` (the capture
42
45
  * seam forbids sharing a module); `machinery-indicator-mirror.test.ts` guards
43
- * the pair on a real program.
46
+ * the pair on a real program. The capture builds each program once and never
47
+ * edits it, so it has no `imports` to read.
44
48
  */
45
- export declare function isExternalOrigin(program: ts.Program, sourceFile: ts.SourceFile, repoRoot: string): boolean;
49
+ export declare function isExternalOrigin(program: ts.Program, sourceFile: ts.SourceFile, repoRoot: string, imports?: ExternalImports): boolean;
50
+ /**
51
+ * Every file a ts-morph project's resolver reached as an external-library
52
+ * import, recorded as the resolver answers (carrick#1731).
53
+ *
54
+ * The compiler keeps that verdict on each program (rule 4 of
55
+ * `isExternalOrigin`), but ts-morph rebuilds the program after any edit to the
56
+ * project, such as a probe file created and removed or a file rewritten and
57
+ * restored, and passes every file it has loaded as a ROOT file. The compiler
58
+ * marks no root file as external, so after the first edit no dependency is.
59
+ * Where a path test cannot answer (a Deno project serves npm types from its own
60
+ * cache), every library type then read as the user's: framework machinery was
61
+ * published as a contract and the printer inlined library types it keeps by
62
+ * name. The resolver's answer for a file does not change with the project's
63
+ * history, so it is kept here for as long as the project lives.
64
+ *
65
+ * The compiler's verdict also carries down: a file a library imports, even by
66
+ * a relative path the resolver answers as not external, is reached while the
67
+ * compiler is inside an external import and is marked external too. A Deno
68
+ * npm package's own relative imports are answered that way, so an answer for
69
+ * an import made from a recorded file is recorded as well.
70
+ */
71
+ export declare class ExternalImports {
72
+ private readonly files;
73
+ /** `factory` as it was, with every answer it gives recorded. */
74
+ recording(factory: ResolutionHostFactory): ResolutionHostFactory;
75
+ /** Whether a resolution reached `fileName` as an external-library import. */
76
+ has(fileName: string): boolean;
77
+ /** Record the external answers to `containingFile`'s imports. */
78
+ private record;
79
+ }
80
+ /** Keep `imports` as the record of `project`, which resolves through it. */
81
+ export declare function registerExternalImports(project: Project, imports: ExternalImports): void;
82
+ /**
83
+ * The record of a project the loader built with a recording resolver, or
84
+ * `undefined` for any other project, which reads the compiler's flag alone.
85
+ */
86
+ export declare function externalImportsOf(project: Project): ExternalImports | undefined;
@@ -33,17 +33,21 @@ import * as path from 'node:path';
33
33
  * types from its own cache, a path with no `node_modules` segment, and
34
34
  * `DenoProject.resolve` hands the compiler `isExternalLibraryImport` from
35
35
  * the graph, which the compiler records on the file. Nothing crosses the
36
- * capture seam; both programs are built with that host. One exclusion: a
37
- * workspace package reached through a `node_modules` symlink is also
38
- * marked external by the compiler but is the user's own source, so a file
39
- * inside the checkout (the nearest `.git` above the service root) that
40
- * carries no `node_modules` segment stays user source.
36
+ * capture seam; both programs are built with that host. The compiler's
37
+ * record does not survive an edit to a ts-morph project, so `imports`, the
38
+ * resolver's answers kept for the project's life, is read beside it
39
+ * (`ExternalImports`, carrick#1731). One exclusion: a workspace package
40
+ * reached through a `node_modules` symlink is also marked external by the
41
+ * compiler but is the user's own source, so a file inside the checkout
42
+ * (the nearest `.git` above the service root) that carries no
43
+ * `node_modules` segment stays user source.
41
44
  *
42
45
  * Lockstep mirror of `isExternalOrigin` in `capture/machinery.ts` (the capture
43
46
  * seam forbids sharing a module); `machinery-indicator-mirror.test.ts` guards
44
- * the pair on a real program.
47
+ * the pair on a real program. The capture builds each program once and never
48
+ * edits it, so it has no `imports` to read.
45
49
  */
46
- export function isExternalOrigin(program, sourceFile, repoRoot) {
50
+ export function isExternalOrigin(program, sourceFile, repoRoot, imports) {
47
51
  const file = sourceFile.fileName.replace(/\\/g, '/');
48
52
  if (file.includes('/.carrick/deno/')) {
49
53
  return true;
@@ -54,7 +58,76 @@ export function isExternalOrigin(program, sourceFile, repoRoot) {
54
58
  if (file.includes('/node_modules/')) {
55
59
  return true;
56
60
  }
57
- return program.isSourceFileFromExternalLibrary(sourceFile) && !isInsideCheckout(file, repoRoot);
61
+ const external = program.isSourceFileFromExternalLibrary(sourceFile) || imports?.has(file) === true;
62
+ return external && !isInsideCheckout(file, repoRoot);
63
+ }
64
+ /**
65
+ * Every file a ts-morph project's resolver reached as an external-library
66
+ * import, recorded as the resolver answers (carrick#1731).
67
+ *
68
+ * The compiler keeps that verdict on each program (rule 4 of
69
+ * `isExternalOrigin`), but ts-morph rebuilds the program after any edit to the
70
+ * project, such as a probe file created and removed or a file rewritten and
71
+ * restored, and passes every file it has loaded as a ROOT file. The compiler
72
+ * marks no root file as external, so after the first edit no dependency is.
73
+ * Where a path test cannot answer (a Deno project serves npm types from its own
74
+ * cache), every library type then read as the user's: framework machinery was
75
+ * published as a contract and the printer inlined library types it keeps by
76
+ * name. The resolver's answer for a file does not change with the project's
77
+ * history, so it is kept here for as long as the project lives.
78
+ *
79
+ * The compiler's verdict also carries down: a file a library imports, even by
80
+ * a relative path the resolver answers as not external, is reached while the
81
+ * compiler is inside an external import and is marked external too. A Deno
82
+ * npm package's own relative imports are answered that way, so an answer for
83
+ * an import made from a recorded file is recorded as well.
84
+ */
85
+ export class ExternalImports {
86
+ files = new Set();
87
+ /** `factory` as it was, with every answer it gives recorded. */
88
+ recording(factory) {
89
+ return (moduleResolutionHost, getCompilerOptions) => {
90
+ const host = factory(moduleResolutionHost, getCompilerOptions);
91
+ return {
92
+ ...host,
93
+ ...(host.resolveModuleNames && {
94
+ resolveModuleNames: (...args) => this.record(args[1], host.resolveModuleNames(...args)),
95
+ }),
96
+ ...(host.resolveTypeReferenceDirectives && {
97
+ resolveTypeReferenceDirectives: (...args) => this.record(args[1], host.resolveTypeReferenceDirectives(...args)),
98
+ }),
99
+ };
100
+ };
101
+ }
102
+ /** Whether a resolution reached `fileName` as an external-library import. */
103
+ has(fileName) {
104
+ return this.files.has(normalised(fileName));
105
+ }
106
+ /** Record the external answers to `containingFile`'s imports. */
107
+ record(containingFile, answers) {
108
+ const fromLibrary = this.has(containingFile);
109
+ for (const answer of answers) {
110
+ if (answer?.resolvedFileName && (fromLibrary || answer.isExternalLibraryImport)) {
111
+ this.files.add(normalised(answer.resolvedFileName));
112
+ }
113
+ }
114
+ return answers;
115
+ }
116
+ }
117
+ function normalised(fileName) {
118
+ return path.resolve(fileName).replace(/\\/g, '/');
119
+ }
120
+ const externalImportsByProject = new WeakMap();
121
+ /** Keep `imports` as the record of `project`, which resolves through it. */
122
+ export function registerExternalImports(project, imports) {
123
+ externalImportsByProject.set(project, imports);
124
+ }
125
+ /**
126
+ * The record of a project the loader built with a recording resolver, or
127
+ * `undefined` for any other project, which reads the compiler's flag alone.
128
+ */
129
+ export function externalImportsOf(project) {
130
+ return externalImportsByProject.get(project);
58
131
  }
59
132
  const checkoutRoots = new Map();
60
133
  /** The checkout the service root sits in: the nearest ancestor holding a
@@ -19,6 +19,12 @@ export interface ProjectLoaderOptions {
19
19
  repoRoot: string;
20
20
  /** Optional path to tsconfig.json (relative to repo root or absolute) */
21
21
  tsconfigPath?: string;
22
+ /**
23
+ * The scanned repo's root, the upper bound of the search for a tsconfig
24
+ * above `repoRoot` when none is named (carrick#1776). Without it only
25
+ * `repoRoot` is searched.
26
+ */
27
+ scanRoot?: string;
22
28
  /** Optional tsconfig snapshot (closed/merged) - preferred over tsconfigPath */
23
29
  tsconfigSnapshot?: TsconfigSnapshot;
24
30
  /** Optional pinned dependencies for this repo */
@@ -69,6 +75,7 @@ export declare class ProjectLoader {
69
75
  private readonly ownerProjects;
70
76
  private readonly repoRoot;
71
77
  private readonly tsconfigPath;
78
+ private readonly scanRoot;
72
79
  private readonly tsconfigSnapshot;
73
80
  private readonly pinnedDependencies;
74
81
  private initialized;
@@ -140,9 +147,10 @@ export declare class ProjectLoader {
140
147
  */
141
148
  getPinnedDependencies(): PinnedDependencySnapshot | undefined;
142
149
  /**
143
- * Find the tsconfig.json file to use
150
+ * The tsconfig to build from: the named one when it exists, else the one
151
+ * `findServiceTsconfig` finds for the service, which capture also reads.
144
152
  *
145
- * @returns Absolute path to tsconfig.json, or undefined if not found
153
+ * @returns Absolute path to the tsconfig, or undefined if not found
146
154
  */
147
155
  private findTsConfig;
148
156
  /**