carrick 0.3.111 → 0.3.113

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 (41) hide show
  1. package/bin/carrick.mjs +3 -0
  2. package/dist/contract.d.ts +6 -0
  3. package/dist/contract.js +5 -0
  4. package/dist/contract.js.map +1 -1
  5. package/dist/hook/post-edit.js +11 -2
  6. package/dist/hook/post-edit.js.map +1 -1
  7. package/dist/hook/post-read.d.ts +2 -0
  8. package/dist/hook/post-read.js +89 -0
  9. package/dist/hook/post-read.js.map +1 -0
  10. package/dist/hook/reuse.d.ts +24 -0
  11. package/dist/hook/reuse.js +58 -2
  12. package/dist/hook/reuse.js.map +1 -1
  13. package/dist/init/settings.js +6 -0
  14. package/dist/init/settings.js.map +1 -1
  15. package/dist/render.d.ts +22 -2
  16. package/dist/render.js +53 -2
  17. package/dist/render.js.map +1 -1
  18. package/dist/update.js +3 -0
  19. package/dist/update.js.map +1 -1
  20. package/package.json +62 -7
  21. package/plugin/.claude-plugin/plugin.json +1 -1
  22. package/plugin/hooks/hooks.json +10 -0
  23. package/scripts/install-scanned-deps.sh +539 -0
  24. package/sidecar/dist/src/capture/api.d.ts +22 -1
  25. package/sidecar/dist/src/capture/check-probe.d.ts +8 -0
  26. package/sidecar/dist/src/capture/check-probe.js +9 -1
  27. package/sidecar/dist/src/capture/check.d.ts +32 -0
  28. package/sidecar/dist/src/capture/check.js +74 -5
  29. package/sidecar/dist/src/capture/checkout.d.ts +38 -0
  30. package/sidecar/dist/src/capture/checkout.js +139 -0
  31. package/sidecar/dist/src/capture/index.d.ts +1 -0
  32. package/sidecar/dist/src/capture/index.js +22 -7
  33. package/sidecar/dist/src/capture/machinery.js +1 -30
  34. package/sidecar/dist/src/capture/project-references.js +2 -1
  35. package/sidecar/dist/src/capture/self-check.d.ts +20 -1
  36. package/sidecar/dist/src/capture/self-check.js +30 -11
  37. package/sidecar/dist/src/origin.js +1 -29
  38. package/sidecar/dist/src/project-loader.d.ts +6 -0
  39. package/sidecar/dist/src/project-loader.js +64 -1
  40. package/sidecar/dist/src/type-inferrer.d.ts +235 -1
  41. package/sidecar/dist/src/type-inferrer.js +901 -111
@@ -27,17 +27,73 @@ import { buildPoisonIndexes } from './check-poison.js';
27
27
  import { openProbeProgram, probeDeepFindings } from './check-deep.js';
28
28
  import { pairFieldReports } from './check-fields.js';
29
29
  import { pairDispatchUnions } from './check-union.js';
30
- function runProcess(command, args, cwd) {
30
+ function runProcess(command, args, cwd, env) {
31
31
  return new Promise((resolve, reject) => {
32
- const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
32
+ const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
33
33
  let stdout = '';
34
34
  let stderr = '';
35
35
  child.stdout.on('data', (d) => (stdout += d.toString()));
36
36
  child.stderr.on('data', (d) => (stderr += d.toString()));
37
37
  child.on('error', reject);
38
- child.on('close', (code) => resolve({ code, stdout, stderr }));
38
+ child.on('close', (code, signal) => resolve({ code, signal, stdout, stderr }));
39
39
  });
40
40
  }
41
+ /** The tail V8 prints on every fatal heap-exhaustion line. */
42
+ const HEAP_OUT_OF_MEMORY = 'JavaScript heap out of memory';
43
+ /**
44
+ * V8's hard size limits print the same tail, and a larger heap does not lift
45
+ * them, so they are not memory deaths.
46
+ */
47
+ const HARD_SIZE_LIMITS = ['invalid table size', 'invalid array length', 'Invalid array buffer length'];
48
+ /**
49
+ * Whether a process's stderr carries V8's fatal heap-exhaustion line
50
+ * (`Reached heap limit`, `Ineffective mark-compacts near heap limit`, and the
51
+ * other forms all end in `JavaScript heap out of memory`), and not one of the
52
+ * hard size limits that end the same way (carrick#2209).
53
+ *
54
+ * The scanner applies the same rule to the sidecar's own stderr
55
+ * (carrick#2208); the two must stay in step.
56
+ */
57
+ export function carriesHeapLimitLine(stderr) {
58
+ return stderr
59
+ .split('\n')
60
+ .some((line) => line.includes(HEAP_OUT_OF_MEMORY) &&
61
+ !HARD_SIZE_LIMITS.some((limit) => line.includes(limit)));
62
+ }
63
+ /** V8's default old-space ceiling on a 64-bit host, in MB. */
64
+ const V8_DEFAULT_HEAP_CEILING_MB = 4096;
65
+ /**
66
+ * The heap a Node child of the sidecar (the check's compiler) is given
67
+ * (carrick#2209).
68
+ *
69
+ * The scanner sizes the sidecar's cap to the machine and passes it as a
70
+ * command-line flag, which a child does not inherit, so the child ran at V8's
71
+ * default. Handing it the same cap would overcommit the box: the sidecar's
72
+ * cap is already 3/4 of memory, and two such caps turn a heap error into an
73
+ * OS kill, which the sizing exists to avoid. So the child gets what is left of
74
+ * the sidecar's cap after what the sidecar holds now (`rssMb`), and only when
75
+ * that is more than V8's default; otherwise `NODE_OPTIONS` is left as it is
76
+ * and the child runs at the default, as before.
77
+ */
78
+ export function childHeap(execArgv, existing, rssMb) {
79
+ let cap;
80
+ for (let i = 0; i < execArgv.length; i++) {
81
+ const match = /^--max[-_]old[-_]space[-_]size(?:=(.*))?$/.exec(execArgv[i]);
82
+ if (!match)
83
+ continue;
84
+ const value = match[1] ?? execArgv[i + 1];
85
+ if (value !== undefined && /^\d+$/.test(value))
86
+ cap = Number(value);
87
+ }
88
+ const budget = cap === undefined ? 0 : Math.floor(cap - rssMb);
89
+ if (budget <= V8_DEFAULT_HEAP_CEILING_MB)
90
+ return { nodeOptions: existing, heapCapMb: null };
91
+ const flag = `--max-old-space-size=${budget}`;
92
+ return {
93
+ nodeOptions: existing && existing.trim() ? `${existing} ${flag}` : flag,
94
+ heapCapMb: budget,
95
+ };
96
+ }
41
97
  /** Resolve the sidecar root (where the vendored pnpm/tsc live) from this bundle. */
42
98
  function sidecarRoot() {
43
99
  // dist/src/capture/check.js -> up three -> sidecar root.
@@ -198,7 +254,8 @@ export async function runCheck(opts, onProgress) {
198
254
  }
199
255
  // ---- Judge (async, off the event loop) ----------------------------------
200
256
  progress('checking', 'type-checking probes');
201
- const tsc = await runProcess(tscPath, ['--noEmit', '--pretty', 'false', '-p', path.join(ws.probesRel, 'tsconfig.json')], ws.workspaceDir);
257
+ const heap = childHeap(process.execArgv, process.env.NODE_OPTIONS, process.memoryUsage().rss / (1024 * 1024));
258
+ const tsc = await runProcess(tscPath, ['--noEmit', '--pretty', 'false', '-p', path.join(ws.probesRel, 'tsconfig.json')], ws.workspaceDir, { ...process.env, NODE_OPTIONS: heap.nodeOptions });
202
259
  const diagnostics = parseTscOutput(tsc.stdout + '\n' + tsc.stderr);
203
260
  // Abnormal tsc termination: OOM/SIGKILL (exit code null), an internal
204
261
  // crash, or a fileless config error (e.g. TS18003, printed without a
@@ -207,7 +264,9 @@ export async function runCheck(opts, onProgress) {
207
264
  // WITH parsed diagnostics is the legitimate incompatible path (tsc exits 1
208
265
  // on ordinary type errors) and stays untouched. Mirrors the install step's
209
266
  // `code === 0` guard.
210
- if (tsc.code !== 0 && diagnostics.length === 0) {
267
+ // A compiler a signal ended never finished, so a pair with no diagnostic
268
+ // was never cleared, whatever it printed first (carrick#2209).
269
+ if (tsc.signal !== null || (tsc.code !== 0 && diagnostics.length === 0)) {
211
270
  const excerpt = scrubPaths((tsc.stderr || tsc.stdout).trim().slice(0, 2000), scrubCtx);
212
271
  errors.push(`tsc terminated abnormally (exit code ${tsc.code ?? 'null'}) ` +
213
272
  `with no parseable diagnostics${excerpt ? `: ${excerpt}` : ''}`);
@@ -224,6 +283,15 @@ export async function runCheck(opts, onProgress) {
224
283
  ...preGated,
225
284
  ...unresolved,
226
285
  ]);
286
+ // How the compiler ended, as fields, so the scanner can tell a memory
287
+ // death from any other abnormal end without reading the text
288
+ // (carrick#2209).
289
+ const compilerEnd = {
290
+ exit_code: tsc.code,
291
+ signal: tsc.signal,
292
+ heap_limit: carriesHeapLimitLine(tsc.stderr),
293
+ heap_cap_mb: heap.heapCapMb,
294
+ };
227
295
  if (cleanup)
228
296
  safeRm(ws);
229
297
  return {
@@ -235,6 +303,7 @@ export async function runCheck(opts, onProgress) {
235
303
  verdicts,
236
304
  degraded_services: dedupeDegraded(degraded),
237
305
  errors,
306
+ compiler_end: compilerEnd,
238
307
  };
239
308
  }
240
309
  const { probeDiagsByPair, poison, globalErrors } = attributeDiagnostics(diagnostics, ws, probing);
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The checkout a service sits in, and the bound an untrusted scan reads
3
+ * inside (carrick#2230).
4
+ *
5
+ * `CARRICK_UNTRUSTED_TREE` (any non-empty value) is the scan's switch, read
6
+ * the same way the scanner reads it. Unset, nothing here constructs a
7
+ * wrapper and every caller passes the compiler what it passed before. Set,
8
+ * the compiler reads no file whose REAL path (links followed) is outside the
9
+ * checkout, outside the sidecar's own install (its TypeScript library files),
10
+ * and outside the directories the caller names as its own (a capture's stub).
11
+ * A refused file reads as missing, so its types become `any`: the posture an
12
+ * unprepared tree already has.
13
+ */
14
+ import ts from 'typescript';
15
+ export declare const UNTRUSTED_TREE_ENV = "CARRICK_UNTRUSTED_TREE";
16
+ /** The checkout the service root sits in: the nearest ancestor holding a
17
+ * `.git` entry (a directory, or the file a worktree carries), else the service
18
+ * root itself. */
19
+ export declare function checkoutRootOf(repoRoot: string): string;
20
+ export declare function isInsideCheckout(file: string, repoRoot: string): boolean;
21
+ /** True when the scan runs under the untrusted-tree switch. */
22
+ export declare function untrustedTree(): boolean;
23
+ /** The real roots an untrusted scan may read beneath. */
24
+ export interface ReadBound {
25
+ readonly roots: readonly string[];
26
+ /** Whether the real path of `file` is beneath a root. Never throws. */
27
+ allows(file: string): boolean;
28
+ }
29
+ /**
30
+ * The bound for a scan of the service at `repoRoot`, or `undefined` when the
31
+ * switch is off. `own` names directories the caller itself owns and reads
32
+ * back (a capture's stub, its scratch).
33
+ */
34
+ export declare function untrustedReadBound(repoRoot: string, own?: readonly string[]): ReadBound | undefined;
35
+ /** `host` with every read the bound refuses answered as a missing file. */
36
+ export declare function boundedCompilerHost(host: ts.CompilerHost, bound: ReadBound | undefined): ts.CompilerHost;
37
+ /** The compiler's own file system, bounded; `ts.sys` itself when no bound. */
38
+ export declare function boundedSys(bound: ReadBound | undefined): ts.System;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The checkout a service sits in, and the bound an untrusted scan reads
3
+ * inside (carrick#2230).
4
+ *
5
+ * `CARRICK_UNTRUSTED_TREE` (any non-empty value) is the scan's switch, read
6
+ * the same way the scanner reads it. Unset, nothing here constructs a
7
+ * wrapper and every caller passes the compiler what it passed before. Set,
8
+ * the compiler reads no file whose REAL path (links followed) is outside the
9
+ * checkout, outside the sidecar's own install (its TypeScript library files),
10
+ * and outside the directories the caller names as its own (a capture's stub).
11
+ * A refused file reads as missing, so its types become `any`: the posture an
12
+ * unprepared tree already has.
13
+ */
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import ts from 'typescript';
18
+ export const UNTRUSTED_TREE_ENV = 'CARRICK_UNTRUSTED_TREE';
19
+ const checkoutRoots = new Map();
20
+ /** The checkout the service root sits in: the nearest ancestor holding a
21
+ * `.git` entry (a directory, or the file a worktree carries), else the service
22
+ * root itself. */
23
+ export function checkoutRootOf(repoRoot) {
24
+ const key = path.resolve(repoRoot);
25
+ const cached = checkoutRoots.get(key);
26
+ if (cached)
27
+ return cached;
28
+ let dir = key;
29
+ let root = key;
30
+ for (;;) {
31
+ if (fs.existsSync(path.join(dir, '.git'))) {
32
+ root = dir;
33
+ break;
34
+ }
35
+ const parent = path.dirname(dir);
36
+ if (parent === dir)
37
+ break;
38
+ dir = parent;
39
+ }
40
+ checkoutRoots.set(key, root);
41
+ return root;
42
+ }
43
+ export function isInsideCheckout(file, repoRoot) {
44
+ const root = checkoutRootOf(repoRoot).replace(/\\/g, '/');
45
+ return file === root || file.startsWith(root.endsWith('/') ? root : root + '/');
46
+ }
47
+ /** True when the scan runs under the untrusted-tree switch. */
48
+ export function untrustedTree() {
49
+ const value = process.env[UNTRUSTED_TREE_ENV];
50
+ return value !== undefined && value !== '';
51
+ }
52
+ /** `p` with every link resolved; a path that does not exist yet resolves
53
+ * through its deepest existing ancestor. */
54
+ function realOf(p) {
55
+ const abs = path.resolve(p);
56
+ const tail = [];
57
+ let dir = abs;
58
+ for (;;) {
59
+ try {
60
+ return path.join(fs.realpathSync(dir), ...tail.reverse());
61
+ }
62
+ catch {
63
+ const parent = path.dirname(dir);
64
+ if (parent === dir)
65
+ return abs;
66
+ tail.push(path.basename(dir));
67
+ dir = parent;
68
+ }
69
+ }
70
+ }
71
+ function within(real, root) {
72
+ return real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
73
+ }
74
+ /** Where the sidecar's own files live: its install, and the TypeScript package
75
+ * whose `lib.*.d.ts` every program reads (beside the sidecar in the npm
76
+ * package, so not always beneath it). */
77
+ function sidecarRoots() {
78
+ const here = path.dirname(fileURLToPath(import.meta.url));
79
+ const roots = [
80
+ path.resolve(here, '..', '..', '..'),
81
+ path.dirname(path.dirname(ts.getDefaultLibFilePath({}))),
82
+ ];
83
+ const configured = process.env.CARRICK_SIDECAR_DIR;
84
+ if (configured && configured.trim() !== '')
85
+ roots.push(path.resolve(configured));
86
+ return roots;
87
+ }
88
+ /**
89
+ * The bound for a scan of the service at `repoRoot`, or `undefined` when the
90
+ * switch is off. `own` names directories the caller itself owns and reads
91
+ * back (a capture's stub, its scratch).
92
+ */
93
+ export function untrustedReadBound(repoRoot, own = []) {
94
+ if (!untrustedTree())
95
+ return undefined;
96
+ const roots = [...new Set([checkoutRootOf(repoRoot), ...sidecarRoots(), ...own].map(realOf))];
97
+ return {
98
+ roots,
99
+ allows: (file) => {
100
+ const real = realOf(file);
101
+ return roots.some((root) => within(real, root));
102
+ },
103
+ };
104
+ }
105
+ /** `host` with every read the bound refuses answered as a missing file. */
106
+ export function boundedCompilerHost(host, bound) {
107
+ if (!bound)
108
+ return host;
109
+ const getSourceFile = host.getSourceFile.bind(host);
110
+ const read = host.readFile.bind(host);
111
+ const exists = host.fileExists.bind(host);
112
+ const dirExists = host.directoryExists?.bind(host);
113
+ const dirs = host.getDirectories?.bind(host);
114
+ const readDirectory = host.readDirectory?.bind(host);
115
+ host.getSourceFile = (file, ...rest) => (bound.allows(file) ? getSourceFile(file, ...rest) : undefined);
116
+ host.readFile = (file) => (bound.allows(file) ? read(file) : undefined);
117
+ host.fileExists = (file) => bound.allows(file) && exists(file);
118
+ if (dirExists)
119
+ host.directoryExists = (dir) => bound.allows(dir) && dirExists(dir);
120
+ if (dirs)
121
+ host.getDirectories = (dir) => (bound.allows(dir) ? dirs(dir) : []);
122
+ if (readDirectory) {
123
+ host.readDirectory = (root, ...rest) => readDirectory(root, ...rest).filter((file) => bound.allows(file));
124
+ }
125
+ return host;
126
+ }
127
+ /** The compiler's own file system, bounded; `ts.sys` itself when no bound. */
128
+ export function boundedSys(bound) {
129
+ if (!bound)
130
+ return ts.sys;
131
+ return {
132
+ ...ts.sys,
133
+ readFile: (file, encoding) => (bound.allows(file) ? ts.sys.readFile(file, encoding) : undefined),
134
+ fileExists: (file) => bound.allows(file) && ts.sys.fileExists(file),
135
+ directoryExists: (dir) => bound.allows(dir) && ts.sys.directoryExists(dir),
136
+ getDirectories: (dir) => (bound.allows(dir) ? ts.sys.getDirectories(dir) : []),
137
+ readDirectory: (root, ...rest) => ts.sys.readDirectory(root, ...rest).filter((file) => bound.allows(file)),
138
+ };
139
+ }
@@ -31,6 +31,7 @@ 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
33
  export { sidecarCompilerOptions } from './compiler-options.js';
34
+ export { isInsideCheckout, untrustedReadBound, UNTRUSTED_TREE_ENV, type ReadBound } from './checkout.js';
34
35
  export { serviceConfigPath } from './project-references.js';
35
36
  export { findServiceTsconfig } from './service-config.js';
36
37
  export { runCheck } from './check.js';
@@ -44,8 +44,10 @@ import { findServiceTsconfig } from './service-config.js';
44
44
  import { placeEmittedTree, surfaceModuleInTree } from './outside-root.js';
45
45
  import { WriteGuard } from './guarded-fs.js';
46
46
  import { sidecarCompilerOptions } from './compiler-options.js';
47
+ import { boundedCompilerHost, boundedSys, untrustedReadBound } from './checkout.js';
47
48
  export { DenoProject, findDenoConfig } from './deno-project.js';
48
49
  export { sidecarCompilerOptions } from './compiler-options.js';
50
+ export { isInsideCheckout, untrustedReadBound, UNTRUSTED_TREE_ENV } from './checkout.js';
49
51
  export { serviceConfigPath } from './project-references.js';
50
52
  export { findServiceTsconfig } from './service-config.js';
51
53
  // v2 check core ("tsc as the judge"). Same bundle, same seam: the sidecar
@@ -132,6 +134,10 @@ export function captureStub(opts) {
132
134
  const packageName = `@carrick/${sanitizeServiceName(opts.serviceName)}`;
133
135
  const stubDir = path.resolve(opts.outDir);
134
136
  const errors = [];
137
+ // Under CARRICK_UNTRUSTED_TREE every compiler read below is held to the
138
+ // checkout, the sidecar's own install and this stub; otherwise undefined
139
+ // and nothing here changes (carrick#2230).
140
+ const bound = untrustedReadBound(repoRoot, [stubDir]);
135
141
  // The named config, else the one the init'd project reads too
136
142
  // (carrick#1776). A service with neither is typed under defaults below.
137
143
  const configPath = opts.tsconfigPath
@@ -192,7 +198,7 @@ export function captureStub(opts) {
192
198
  checkJs: false,
193
199
  resolveJsonModule: true,
194
200
  },
195
- }, ts.sys, repoRoot);
201
+ }, boundedSys(bound), repoRoot);
196
202
  }
197
203
  else {
198
204
  // Each anchor is typed under the project that owns its file: the named
@@ -260,7 +266,7 @@ export function captureStub(opts) {
260
266
  // ---- Phase A: analysis program over placeholder entry + anchor sources ----
261
267
  let resolved;
262
268
  const progress = opts.onProgress ?? (() => { });
263
- const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath, guard, progress };
269
+ const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath, guard, progress, bound };
264
270
  try {
265
271
  resolved = ownerGroups && emitProject
266
272
  ? resolveAnchorsByOwner(opts, ownerGroups, emitProject, analysisCtx, errors)
@@ -302,7 +308,7 @@ export function captureStub(opts) {
302
308
  written = emitDeclarations({
303
309
  rootNames: [entryPath, ...augmentationSources],
304
310
  options: emitOptions,
305
- host: deno?.host(emitOptions),
311
+ host: programHost(emitOptions, deno, bound),
306
312
  staging,
307
313
  entryDir,
308
314
  });
@@ -490,8 +496,8 @@ export function captureStub(opts) {
490
496
  pinned,
491
497
  bareCheckout,
492
498
  repoRoot: dependencyRoot,
493
- compilerHost: deno || Object.keys(rewritten.installs).length > 0
494
- ? (options) => withInstalledPackages(options, rewritten.installs, deno ? deno.host(options) : undefined)
499
+ compilerHost: deno || bound || Object.keys(rewritten.installs).length > 0
500
+ ? (options) => boundedCompilerHost(withInstalledPackages(options, rewritten.installs, deno ? deno.host(options) : undefined), bound)
495
501
  : undefined,
496
502
  });
497
503
  const fidelity = computeFidelity(aliases);
@@ -691,6 +697,15 @@ function resolveAnchorsByOwner(opts, groups, emit, ctx, errors) {
691
697
  }
692
698
  return resolved;
693
699
  }
700
+ /**
701
+ * The compiler host a capture program is built on: the Deno graph's, else the
702
+ * default one (`undefined`), each held to the read bound when there is one.
703
+ */
704
+ function programHost(options, deno, bound) {
705
+ if (!bound)
706
+ return deno?.host(options);
707
+ return boundedCompilerHost(deno ? deno.host(options) : ts.createCompilerHost(options), bound);
708
+ }
694
709
  /** Phase A: build the placeholder entry, then resolve every anchor. */
695
710
  function resolveAnchors(opts, parsed, ctx, deno) {
696
711
  const placeholderLines = ['// Carrick capture v2 analysis placeholder.'];
@@ -708,7 +723,7 @@ function resolveAnchors(opts, parsed, ctx, deno) {
708
723
  ...parsed.options,
709
724
  noEmit: true,
710
725
  });
711
- const program = ts.createProgram([ctx.entryPath, ...anchorSources, ...(deno?.globals ?? [])], options, deno?.host(options));
726
+ const program = ts.createProgram([ctx.entryPath, ...anchorSources, ...(deno?.globals ?? [])], options, programHost(options, deno, ctx.bound));
712
727
  // The checker binds every file of the program when it is first asked for,
713
728
  // which the first anchor would otherwise do: asked for here, so that the
714
729
  // report below says the program is whole before any anchor is read.
@@ -731,7 +746,7 @@ function resolveAnchors(opts, parsed, ctx, deno) {
731
746
  if (!fromEntry.has(specifier)) {
732
747
  fromEntry.set(specifier, (deno
733
748
  ? deno.resolve(specifier, ctx.entryPath, options)
734
- : ts.resolveModuleName(specifier, ctx.entryPath, options, ts.sys, undefined, undefined, entryMode)
749
+ : ts.resolveModuleName(specifier, ctx.entryPath, options, boundedSys(ctx.bound), undefined, undefined, entryMode)
735
750
  .resolvedModule)?.resolvedFileName);
736
751
  }
737
752
  return fromEntry.get(specifier);
@@ -18,8 +18,7 @@
18
18
  * user payload that merely shares a member name can never trip it.
19
19
  */
20
20
  import ts from 'typescript';
21
- import * as fs from 'node:fs';
22
- import * as path from 'node:path';
21
+ import { isInsideCheckout } from './checkout.js';
23
22
  /**
24
23
  * Strongly-discriminating member names of HTTP transport machinery. Kept
25
24
  * identical to `MACHINERY_MEMBER_INDICATORS` in `type-inferrer.ts`. These are
@@ -171,34 +170,6 @@ export function isExternalOrigin(program, sourceFile, repoRoot) {
171
170
  }
172
171
  return program.isSourceFileFromExternalLibrary(sourceFile) && !isInsideCheckout(file, repoRoot);
173
172
  }
174
- const checkoutRoots = new Map();
175
- /** The checkout the service root sits in: the nearest ancestor holding a
176
- * `.git` entry (a directory, or the file a worktree carries), else the service
177
- * root itself. */
178
- function checkoutRootOf(repoRoot) {
179
- const key = path.resolve(repoRoot);
180
- const cached = checkoutRoots.get(key);
181
- if (cached)
182
- return cached;
183
- let dir = key;
184
- let root = key;
185
- for (;;) {
186
- if (fs.existsSync(path.join(dir, '.git'))) {
187
- root = dir;
188
- break;
189
- }
190
- const parent = path.dirname(dir);
191
- if (parent === dir)
192
- break;
193
- dir = parent;
194
- }
195
- checkoutRoots.set(key, root);
196
- return root;
197
- }
198
- function isInsideCheckout(file, repoRoot) {
199
- const root = checkoutRootOf(repoRoot).replace(/\\/g, '/');
200
- return file === root || file.startsWith(root.endsWith('/') ? root : root + '/');
201
- }
202
173
  /**
203
174
  * True when the symbol is declared in a runtime/library origin. Works on a bare
204
175
  * checkout: the DOM `Response`/`Request` resolve from the bundled
@@ -19,10 +19,11 @@
19
19
  */
20
20
  import * as path from 'node:path';
21
21
  import ts from 'typescript';
22
+ import { boundedSys, untrustedReadBound } from './checkout.js';
22
23
  /** Parse one config; throws on a file that cannot be read or parsed. */
23
24
  function parseConfig(configPath, extendedConfigCache) {
24
25
  const host = {
25
- ...ts.sys,
26
+ ...boundedSys(untrustedReadBound(path.dirname(configPath))),
26
27
  onUnRecoverableConfigFileDiagnostic: (d) => {
27
28
  throw new Error(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
28
29
  },
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * The stub tree is typechecked standalone with `skipLibCheck: false` --
6
6
  * spike-verified as load-bearing: the tree is entirely .d.ts and skipLibCheck
7
- * skips declaration files wholesale, making the gate vacuous. When the
7
+ * skips declaration files wholesale, making the gate vacuous. It also sets
8
+ * `skipDefaultLibCheck: true`, which skips only the compiler's own default
9
+ * lib files, never a stub file (see `SELF_CHECK_OPTIONS`). When the
8
10
  * source repo has node_modules, resolution is pointed at it via a temporary
9
11
  * node_modules symlink inside the stub, so externals resolve exactly as they
10
12
  * will at check time against installed pins.
@@ -46,3 +48,20 @@ export interface SelfCheckArgs {
46
48
  compilerHost?: (options: ts.CompilerOptions) => ts.CompilerHost;
47
49
  }
48
50
  export declare function selfCheckStub(args: SelfCheckArgs): CaptureAliasRecord[];
51
+ /**
52
+ * The options of every self-check program.
53
+ *
54
+ * `skipLibCheck: false` keeps every stub `.d.ts` checked (see module header).
55
+ * `skipDefaultLibCheck: true` skips only files the program loaded as a default
56
+ * lib (the default lib, `lib` entries, `/// <reference lib>` targets), which
57
+ * was most of a capture's check time (carrick#2158). It is safe here because
58
+ * the self-check reads only resolution diagnostics (2307, 2792, `Cannot find
59
+ * name|namespace`), attributes them only through the stub tree, and the
60
+ * compiler reports each at the referencing stub node when it checks that stub.
61
+ *
62
+ * It must NOT be set on the judge's `tsc` (`CHECKER_TSCONFIG`): some checks of
63
+ * a merged interface run only when its FIRST declaration is checked, so a stub
64
+ * that augments `interface Window { 0: number }` loses its TS2411 when
65
+ * `lib.dom.d.ts` is skipped, and the judge counts any stub diagnostic.
66
+ */
67
+ export declare const SELF_CHECK_OPTIONS: Readonly<ts.CompilerOptions>;
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * The stub tree is typechecked standalone with `skipLibCheck: false` --
6
6
  * spike-verified as load-bearing: the tree is entirely .d.ts and skipLibCheck
7
- * skips declaration files wholesale, making the gate vacuous. When the
7
+ * skips declaration files wholesale, making the gate vacuous. It also sets
8
+ * `skipDefaultLibCheck: true`, which skips only the compiler's own default
9
+ * lib files, never a stub file (see `SELF_CHECK_OPTIONS`). When the
8
10
  * source repo has node_modules, resolution is pointed at it via a temporary
9
11
  * node_modules symlink inside the stub, so externals resolve exactly as they
10
12
  * will at check time against installed pins.
@@ -78,17 +80,34 @@ export function selfCheckStub(args) {
78
80
  args.guard.unlink(linkPath);
79
81
  }
80
82
  }
83
+ /**
84
+ * The options of every self-check program.
85
+ *
86
+ * `skipLibCheck: false` keeps every stub `.d.ts` checked (see module header).
87
+ * `skipDefaultLibCheck: true` skips only files the program loaded as a default
88
+ * lib (the default lib, `lib` entries, `/// <reference lib>` targets), which
89
+ * was most of a capture's check time (carrick#2158). It is safe here because
90
+ * the self-check reads only resolution diagnostics (2307, 2792, `Cannot find
91
+ * name|namespace`), attributes them only through the stub tree, and the
92
+ * compiler reports each at the referencing stub node when it checks that stub.
93
+ *
94
+ * It must NOT be set on the judge's `tsc` (`CHECKER_TSCONFIG`): some checks of
95
+ * a merged interface run only when its FIRST declaration is checked, so a stub
96
+ * that augments `interface Window { 0: number }` loses its TS2411 when
97
+ * `lib.dom.d.ts` is skipped, and the judge counts any stub diagnostic.
98
+ */
99
+ export const SELF_CHECK_OPTIONS = sidecarCompilerOptions({
100
+ noEmit: true,
101
+ strict: true,
102
+ // MUST be false: the whole stub tree is .d.ts (see module header).
103
+ skipLibCheck: false,
104
+ skipDefaultLibCheck: true,
105
+ module: ts.ModuleKind.ESNext,
106
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
107
+ types: [],
108
+ });
81
109
  function runSelfCheck(args, treeFiles, repaired) {
82
- const options = sidecarCompilerOptions({
83
- noEmit: true,
84
- strict: true,
85
- // MUST be false: the whole stub tree is .d.ts (see module header).
86
- skipLibCheck: false,
87
- module: ts.ModuleKind.ESNext,
88
- moduleResolution: ts.ModuleResolutionKind.Bundler,
89
- types: [],
90
- });
91
- const program = ts.createProgram(treeFiles, options, args.compilerHost?.(options));
110
+ const program = ts.createProgram(treeFiles, SELF_CHECK_OPTIONS, args.compilerHost?.(SELF_CHECK_OPTIONS));
92
111
  const checker = program.getTypeChecker();
93
112
  const diagnostics = ts.getPreEmitDiagnostics(program);
94
113
  const surfaceAbs = path.resolve(args.surfaceAbsPath);
@@ -13,8 +13,8 @@
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 * as fs from 'node:fs';
17
16
  import * as path from 'node:path';
17
+ import { isInsideCheckout } from './capture/index.js';
18
18
  /**
19
19
  * True when a declaration's source file is runtime/library origin rather than
20
20
  * user source. Four answers, in order:
@@ -129,31 +129,3 @@ export function registerExternalImports(project, imports) {
129
129
  export function externalImportsOf(project) {
130
130
  return externalImportsByProject.get(project);
131
131
  }
132
- const checkoutRoots = new Map();
133
- /** The checkout the service root sits in: the nearest ancestor holding a
134
- * `.git` entry (a directory, or the file a worktree carries), else the service
135
- * root itself. */
136
- function checkoutRootOf(repoRoot) {
137
- const key = path.resolve(repoRoot);
138
- const cached = checkoutRoots.get(key);
139
- if (cached)
140
- return cached;
141
- let dir = key;
142
- let root = key;
143
- for (;;) {
144
- if (fs.existsSync(path.join(dir, '.git'))) {
145
- root = dir;
146
- break;
147
- }
148
- const parent = path.dirname(dir);
149
- if (parent === dir)
150
- break;
151
- dir = parent;
152
- }
153
- checkoutRoots.set(key, root);
154
- return root;
155
- }
156
- function isInsideCheckout(file, repoRoot) {
157
- const root = checkoutRootOf(repoRoot).replace(/\\/g, '/');
158
- return file === root || file.startsWith(root.endsWith('/') ? root : root + '/');
159
- }
@@ -95,6 +95,12 @@ export declare class ProjectLoader {
95
95
  * Convert a TsconfigSnapshot to ts-morph CompilerOptions
96
96
  */
97
97
  private snapshotToCompilerOptions;
98
+ /**
99
+ * Under CARRICK_UNTRUSTED_TREE, the file system every project reads
100
+ * through: nothing whose real path is outside the checkout (carrick#2230).
101
+ * Nothing at all otherwise, so a project is built as it always was.
102
+ */
103
+ private fileSystemOption;
98
104
  /** A ts-morph project built from one tsconfig and the files it lists. */
99
105
  private projectFromConfig;
100
106
  /**