carrick 0.3.107 → 0.3.108

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carrick",
3
- "version": "0.3.107",
3
+ "version": "0.3.108",
4
4
  "description": "Maps your entire TypeScript codebase across services and repositories, giving AI agents full context on existing types, routes, and function behaviours over MCP before they write duplicate or breaking code.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -58,11 +58,11 @@
58
58
  "zod": "^3.23.0"
59
59
  },
60
60
  "optionalDependencies": {
61
- "@carrick-tools/cli-darwin-arm64": "0.3.107",
62
- "@carrick-tools/cli-darwin-x64": "0.3.107",
63
- "@carrick-tools/cli-linux-arm64": "0.3.107",
64
- "@carrick-tools/cli-linux-x64": "0.3.107",
65
- "@carrick-tools/cli-win32-x64": "0.3.107"
61
+ "@carrick-tools/cli-darwin-arm64": "0.3.108",
62
+ "@carrick-tools/cli-darwin-x64": "0.3.108",
63
+ "@carrick-tools/cli-linux-arm64": "0.3.108",
64
+ "@carrick-tools/cli-linux-x64": "0.3.108",
65
+ "@carrick-tools/cli-win32-x64": "0.3.108"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@types/node": "^24.13.3",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "carrick",
3
3
  "description": "Claude Code plugin for Carrick, which indexes TypeScript codebases across service and repository boundaries. After each edit it adds the file's routes, calls, and cross-service type mismatches to the session, and it registers Carrick's language server. It pairs with the Carrick MCP server, which lets agents search functions by intent rather than name.",
4
- "version": "0.3.107",
4
+ "version": "0.3.108",
5
5
  "author": {
6
6
  "name": "Carrick",
7
7
  "email": "hello@carrick.tools"
@@ -346,6 +346,14 @@ export interface CaptureStubResult {
346
346
  ts_version: string;
347
347
  errors: string[];
348
348
  }
349
+ /**
350
+ * The stages of a capture that it reports as it reaches them (carrick#1916),
351
+ * in order: the program the anchors are read in is being built; the anchors
352
+ * are being resolved in it; the declarations are being emitted; the stub is
353
+ * being checked. A capture whose anchors belong to several projects goes
354
+ * through the first two once per project.
355
+ */
356
+ export type CapturePhase = 'program' | 'anchors' | 'emit' | 'self-check';
349
357
  export interface CaptureStubOptions {
350
358
  repoRoot: string;
351
359
  serviceName: string;
@@ -360,6 +368,13 @@ export interface CaptureStubOptions {
360
368
  * lie inside it only beneath a `.carrick` directory (carrick#1768).
361
369
  */
362
370
  scanRoot?: string;
371
+ /**
372
+ * Told each stage as the capture reaches it, and each anchor as it is
373
+ * resolved (`message` is then `<done> of <total>`). Called by the work
374
+ * itself, between units, so a capture that stops finishing them stops
375
+ * reporting. It changes nothing the capture writes.
376
+ */
377
+ onProgress?: (phase: CapturePhase, message: string) => void;
363
378
  }
364
379
  /** Wire protocol of a matched pair (drives the direction table). */
365
380
  export type ProbeProtocol = 'http' | 'graphql' | 'socket' | 'pubsub';
@@ -14,6 +14,8 @@
14
14
  * IsFormBody gate fired (TS2344) -> unverifiable (form-encoded body)
15
15
  * IsByteBody gate fired (TS2344) -> unverifiable (bytes, even agreeing)
16
16
  * raw-text read marked on a side -> unverifiable (text, even agreeing)
17
+ * assignment error, sent union taken in part by a weak expected type
18
+ * -> unverifiable (carrick#1995)
17
19
  * assignment-class error -> incompatible
18
20
  * no diagnostics -> compatible [lowest precedence]
19
21
  *
@@ -27,6 +29,7 @@ import type { CheckVerdict } from './api.js';
27
29
  import { type ProbePlan, type Side } from './check-probe.js';
28
30
  import { type ScrubContext } from './check-scrub.js';
29
31
  import type { PairDeepFindings } from './check-deep.js';
32
+ import type { DispatchUnion } from './check-union.js';
30
33
  import { type PairFieldReport } from './check-fields.js';
31
34
  export interface RawDiagnostic {
32
35
  /** Workspace-relative, forward-slash file path (empty for global errors). */
@@ -66,6 +69,12 @@ export interface ClassifyInput {
66
69
  * run, in which case the tsc text stands alone.
67
70
  */
68
71
  fieldReport?: PairFieldReport;
72
+ /**
73
+ * Set when the sent side is a union the expected side takes only in part,
74
+ * every other member failing by nothing but the compiler's weak-type check
75
+ * (carrick#1995). Read only where the decisive assignment failed.
76
+ */
77
+ dispatchUnion?: DispatchUnion;
69
78
  /**
70
79
  * The side whose capture record says it reads the body as raw text
71
80
  * (carrick#1842), the sent side when both do. Read from the record, not the
@@ -14,6 +14,8 @@
14
14
  * IsFormBody gate fired (TS2344) -> unverifiable (form-encoded body)
15
15
  * IsByteBody gate fired (TS2344) -> unverifiable (bytes, even agreeing)
16
16
  * raw-text read marked on a side -> unverifiable (text, even agreeing)
17
+ * assignment error, sent union taken in part by a weak expected type
18
+ * -> unverifiable (carrick#1995)
17
19
  * assignment-class error -> incompatible
18
20
  * no diagnostics -> compatible [lowest precedence]
19
21
  *
@@ -220,6 +222,24 @@ export function classifyPair(input) {
220
222
  // the `string` it becomes) has no error on this line and is not a drift.
221
223
  const decisiveLine = decisiveAssignmentLine(plan);
222
224
  const assignDiag = probeDiags.find((d) => d.line === decisiveLine && ASSIGNMENT_CODES.has(d.code));
225
+ // 5a. Unless the mismatch is a sent union the expected type takes in part,
226
+ // the members it rejects sharing no field with a type whose every field
227
+ // is optional (carrick#1995). That is a route answering a body per
228
+ // branch read by a caller of one branch: no member breaks a reader whose
229
+ // every read allows absence, and which member the call receives is not
230
+ // in the types. Not a fact either way, and no side to retype.
231
+ if (assignDiag && input.dispatchUnion) {
232
+ const { members, agreeing } = input.dispatchUnion;
233
+ const sent = plan.direction.sent;
234
+ const expected = plan.direction.expected;
235
+ return {
236
+ ...base,
237
+ bucket: 'unverifiable',
238
+ gate: `${sent}:union`,
239
+ diagnostic: `the ${sent} type is a union of ${members} bodies; the ${expected} type, every field optional, accepts ${agreeing} of them and shares no field with the other ${members - agreeing}, so the call reads one of the bodies the route answers and the types do not say which.`,
240
+ ...notAFact(`the ${sent} type is a union of bodies and the ${expected} type, every field optional, accepts only some of them`),
241
+ };
242
+ }
223
243
  if (assignDiag) {
224
244
  const text = scrubDiagnostic(assignDiag.message, scrubCtx, plan.sentEndpoint.alias, plan.expectedEndpoint.alias);
225
245
  return {
@@ -28,6 +28,7 @@
28
28
  *
29
29
  * Seam: node builtins + `typescript` + this bundle only.
30
30
  */
31
+ import ts from 'typescript';
31
32
  import type { ProbePlan, Side } from './check-probe.js';
32
33
  import type { ProbeProgram } from './check-deep.js';
33
34
  /** What kind of difference one field path carries. */
@@ -69,6 +70,29 @@ export interface PairFieldReport {
69
70
  * how many more there are rather than pretending the list is complete.
70
71
  */
71
72
  export declare const MAX_NAMED_FIELDS = 8;
73
+ /**
74
+ * The two types the judge's decisive assignment compared for one plan, read in
75
+ * the probe program, with the compiler's own assignability relation.
76
+ * `undefined` when the probe or either alias cannot be read, or the compiler
77
+ * build exposes no relation: every statement made without them would be a
78
+ * guess.
79
+ *
80
+ * The sent side is whatever that assignment actually sent: the GraphQL
81
+ * comparand where one exists, then the JSON wire form where serialising
82
+ * changes the type. Reading `sent` there instead would describe a type the
83
+ * judge did not compare, which is the one way a reader of this pair could
84
+ * contradict it.
85
+ */
86
+ export declare function comparedTypes(opened: ProbeProgram, plan: ProbePlan): {
87
+ compared: ts.Type;
88
+ expected: {
89
+ type: ts.Type;
90
+ node: ts.Node;
91
+ };
92
+ wireApplied: boolean;
93
+ checker: ts.TypeChecker;
94
+ isAssignableTo: (source: ts.Type, target: ts.Type) => boolean;
95
+ } | undefined;
72
96
  /**
73
97
  * Field reports for every plan whose probe the program could read, keyed by
74
98
  * pair id. A plan with no entry has no report, which is not a claim that its
@@ -54,6 +54,56 @@ function assignabilityOf(checker) {
54
54
  const fn = checker.isTypeAssignableTo;
55
55
  return typeof fn === 'function' ? fn.bind(checker) : undefined;
56
56
  }
57
+ /**
58
+ * The two types the judge's decisive assignment compared for one plan, read in
59
+ * the probe program, with the compiler's own assignability relation.
60
+ * `undefined` when the probe or either alias cannot be read, or the compiler
61
+ * build exposes no relation: every statement made without them would be a
62
+ * guess.
63
+ *
64
+ * The sent side is whatever that assignment actually sent: the GraphQL
65
+ * comparand where one exists, then the JSON wire form where serialising
66
+ * changes the type. Reading `sent` there instead would describe a type the
67
+ * judge did not compare, which is the one way a reader of this pair could
68
+ * contradict it.
69
+ */
70
+ export function comparedTypes(opened, plan) {
71
+ const { program, checker, probesDir } = opened;
72
+ const isAssignableTo = assignabilityOf(checker);
73
+ if (!isAssignableTo)
74
+ return undefined;
75
+ const file = program.getSourceFile(`${probesDir}/probes/${plan.fileName}`.split('\\').join('/'));
76
+ const source = file ??
77
+ program.getSourceFiles().find((sf) => sf.fileName.endsWith(`/probes/${plan.fileName}`));
78
+ if (!source)
79
+ return undefined;
80
+ const declared = declaredConstType(source, checker, 'sentComparand') ??
81
+ declaredConstType(source, checker, 'sent');
82
+ const expected = declaredConstType(source, checker, 'expected');
83
+ if (!declared || !expected)
84
+ return undefined;
85
+ const wire = declaredConstType(source, checker, 'sentWire');
86
+ // Whether serialising changes anything observable about the sent type.
87
+ const wireApplied = wire !== undefined &&
88
+ !(isAssignableTo(wire.type, declared.type) && isAssignableTo(declared.type, wire.type));
89
+ // The DECLARED type unless serialising really changed it.
90
+ //
91
+ // The probe declares its wire comparand through a conditional alias that
92
+ // short-circuits back to the declared type whenever that already assigns,
93
+ // and a conditional the checker has not had to resolve carries no members
94
+ // to walk. Reading it unconditionally therefore emptied the field report on
95
+ // exactly the pairs that AGREE — the ones whose only statement is an
96
+ // optionality gap or the wire note (carrick#1341). Where serialisation did
97
+ // change the type the wire form is a mapped type with real members, and it
98
+ // stays the thing compared, because that is what the judge judged.
99
+ return {
100
+ compared: wireApplied ? wire.type : declared.type,
101
+ expected,
102
+ wireApplied,
103
+ checker,
104
+ isAssignableTo,
105
+ };
106
+ }
57
107
  /**
58
108
  * Field reports for every plan whose probe the program could read, keyed by
59
109
  * pair id. A plan with no entry has no report, which is not a claim that its
@@ -63,44 +113,12 @@ export function pairFieldReports(opened, plans) {
63
113
  const results = new Map();
64
114
  if (!opened)
65
115
  return results;
66
- const { program, checker, probesDir } = opened;
67
- const isAssignableTo = assignabilityOf(checker);
68
- if (!isAssignableTo)
69
- return results;
70
116
  for (const plan of plans) {
71
- const file = program.getSourceFile(`${probesDir}/probes/${plan.fileName}`.split('\\').join('/'));
72
- const source = file ??
73
- program
74
- .getSourceFiles()
75
- .find((sf) => sf.fileName.endsWith(`/probes/${plan.fileName}`));
76
- if (!source)
77
- continue;
78
- // Whatever the judge's decisive assignment actually sent: the GraphQL
79
- // comparand where one exists, then the JSON wire form where one exists.
80
- // Reading `sent` there instead would describe a type the judge did not
81
- // compare, which is the one way this walk could contradict it.
82
- const declared = declaredConstType(source, checker, 'sentComparand') ??
83
- declaredConstType(source, checker, 'sent');
84
- const expected = declaredConstType(source, checker, 'expected');
85
- if (!declared || !expected)
117
+ const pair = comparedTypes(opened, plan);
118
+ if (!pair)
86
119
  continue;
87
- const wire = declaredConstType(source, checker, 'sentWire');
88
- // Whether serialising changes anything observable about the sent type.
89
- const wireChanges = wire !== undefined &&
90
- !(isAssignableTo(wire.type, declared.type) && isAssignableTo(declared.type, wire.type));
91
- // Walk the DECLARED type unless serialising really changed it.
92
- //
93
- // The probe declares its wire comparand through a conditional alias that
94
- // short-circuits back to the declared type whenever that already assigns,
95
- // and a conditional the checker has not had to resolve carries no members
96
- // to walk. Reading it unconditionally therefore emptied the report on
97
- // exactly the pairs that AGREE — the ones whose only statement is an
98
- // optionality gap or the wire note (carrick#1341). Where serialisation
99
- // did change the type the wire form is a mapped type with real members,
100
- // and it stays the thing compared, because that is what the judge judged.
101
- const compared = wireChanges ? wire.type : declared.type;
102
- const report = diffReport(compared, expected.type, checker, isAssignableTo, expected.node, plan.spec.protocol === 'graphql' ? GRAPHQL_SERVER_SUPPLIED : NONE_SERVER_SUPPLIED);
103
- report.wireApplied = wireChanges;
120
+ const report = diffReport(pair.compared, pair.expected.type, pair.checker, pair.isAssignableTo, pair.expected.node, plan.spec.protocol === 'graphql' ? GRAPHQL_SERVER_SUPPLIED : NONE_SERVER_SUPPLIED);
121
+ report.wireApplied = pair.wireApplied;
104
122
  results.set(plan.pairId, report);
105
123
  }
106
124
  return results;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * A sent union whose members the expected type takes only in part, where the
3
+ * rest fail by nothing but the compiler's weak-type check (carrick#1995).
4
+ *
5
+ * A handler that answers a different body per branch (an action that switches
6
+ * on a field of the request) publishes the union of those bodies. A caller of
7
+ * one branch reads the response with a type whose every field is optional. The
8
+ * compiler then rejects each member that shares no field with that type
9
+ * (TS2559, "has no properties in common"): a type whose every property is
10
+ * optional is a "weak type", and the check exists to catch a misspelt property
11
+ * bag. It is not a soundness rule. At runtime every member satisfies a type
12
+ * whose reads all allow absence, and the call only ever receives the body of
13
+ * the branch it asked for, which the types do not say.
14
+ *
15
+ * So the pair is neither compatible nor incompatible: the judge cannot say
16
+ * which member the call receives. Kept narrow on purpose:
17
+ * - the sent type must be a union with at least one member that assigns;
18
+ * - the expected type must be one object type whose every property is
19
+ * optional, with no index or call signatures;
20
+ * - every member that does not assign must be an object with properties, none
21
+ * of which the expected type names.
22
+ * A sent type that is not a union and shares no field with a weak expected
23
+ * type is the misspelling the check exists for, and stays a mismatch.
24
+ *
25
+ * Read in the probe program, over the two types the decisive assignment
26
+ * compared, with the compiler's own relation. Seam: typescript + this bundle.
27
+ */
28
+ import ts from 'typescript';
29
+ import type { ProbePlan } from './check-probe.js';
30
+ import type { ProbeProgram } from './check-deep.js';
31
+ export interface DispatchUnion {
32
+ /** Members of the sent union. */
33
+ members: number;
34
+ /** Members the expected type takes. */
35
+ agreeing: number;
36
+ }
37
+ /** The finding for every plan whose compared types have this shape, by pair id. */
38
+ export declare function pairDispatchUnions(opened: ProbeProgram | undefined, plans: ProbePlan[]): Map<string, DispatchUnion>;
39
+ /** The shape above for one compared pair, or `undefined` when it does not hold. */
40
+ export declare function dispatchUnion(sent: ts.Type, expected: ts.Type, checker: ts.TypeChecker, isAssignableTo: (source: ts.Type, target: ts.Type) => boolean): DispatchUnion | undefined;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * A sent union whose members the expected type takes only in part, where the
3
+ * rest fail by nothing but the compiler's weak-type check (carrick#1995).
4
+ *
5
+ * A handler that answers a different body per branch (an action that switches
6
+ * on a field of the request) publishes the union of those bodies. A caller of
7
+ * one branch reads the response with a type whose every field is optional. The
8
+ * compiler then rejects each member that shares no field with that type
9
+ * (TS2559, "has no properties in common"): a type whose every property is
10
+ * optional is a "weak type", and the check exists to catch a misspelt property
11
+ * bag. It is not a soundness rule. At runtime every member satisfies a type
12
+ * whose reads all allow absence, and the call only ever receives the body of
13
+ * the branch it asked for, which the types do not say.
14
+ *
15
+ * So the pair is neither compatible nor incompatible: the judge cannot say
16
+ * which member the call receives. Kept narrow on purpose:
17
+ * - the sent type must be a union with at least one member that assigns;
18
+ * - the expected type must be one object type whose every property is
19
+ * optional, with no index or call signatures;
20
+ * - every member that does not assign must be an object with properties, none
21
+ * of which the expected type names.
22
+ * A sent type that is not a union and shares no field with a weak expected
23
+ * type is the misspelling the check exists for, and stays a mismatch.
24
+ *
25
+ * Read in the probe program, over the two types the decisive assignment
26
+ * compared, with the compiler's own relation. Seam: typescript + this bundle.
27
+ */
28
+ import ts from 'typescript';
29
+ import { comparedTypes } from './check-fields.js';
30
+ /** The finding for every plan whose compared types have this shape, by pair id. */
31
+ export function pairDispatchUnions(opened, plans) {
32
+ const results = new Map();
33
+ if (!opened)
34
+ return results;
35
+ for (const plan of plans) {
36
+ const pair = comparedTypes(opened, plan);
37
+ if (!pair)
38
+ continue;
39
+ const finding = dispatchUnion(pair.compared, pair.expected.type, pair.checker, pair.isAssignableTo);
40
+ if (finding)
41
+ results.set(plan.pairId, finding);
42
+ }
43
+ return results;
44
+ }
45
+ /** The shape above for one compared pair, or `undefined` when it does not hold. */
46
+ export function dispatchUnion(sent, expected, checker, isAssignableTo) {
47
+ if (!sent.isUnion())
48
+ return undefined;
49
+ const names = weakPropertyNames(expected, checker);
50
+ if (!names)
51
+ return undefined;
52
+ let agreeing = 0;
53
+ for (const member of sent.types) {
54
+ if (isAssignableTo(member, expected)) {
55
+ agreeing += 1;
56
+ continue;
57
+ }
58
+ if (!sharesNoProperty(member, names, checker))
59
+ return undefined;
60
+ }
61
+ if (agreeing === 0 || agreeing === sent.types.length)
62
+ return undefined;
63
+ return { members: sent.types.length, agreeing };
64
+ }
65
+ /**
66
+ * The property names of a weak object type: one object type, at least one
67
+ * property, every property optional, no index signature and no call or
68
+ * construct signature. `undefined` for any other type.
69
+ */
70
+ function weakPropertyNames(type, checker) {
71
+ if (!(type.flags & ts.TypeFlags.Object))
72
+ return undefined;
73
+ if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0 ||
74
+ checker.getSignaturesOfType(type, ts.SignatureKind.Construct).length > 0 ||
75
+ checker.getIndexInfosOfType(type).length > 0) {
76
+ return undefined;
77
+ }
78
+ const properties = checker.getPropertiesOfType(type);
79
+ if (properties.length === 0)
80
+ return undefined;
81
+ if (!properties.every((property) => (property.flags & ts.SymbolFlags.Optional) !== 0)) {
82
+ return undefined;
83
+ }
84
+ return new Set(properties.map((property) => property.name));
85
+ }
86
+ /** Whether `member` is an object with properties, none of them in `names`. */
87
+ function sharesNoProperty(member, names, checker) {
88
+ if (!(member.flags & ts.TypeFlags.Object))
89
+ return false;
90
+ const properties = checker.getPropertiesOfType(member);
91
+ return properties.length > 0 && properties.every((property) => !names.has(property.name));
92
+ }
@@ -26,6 +26,7 @@ import { assembleWorkspace, writeProbes, } from './check-workspace.js';
26
26
  import { buildPoisonIndexes } from './check-poison.js';
27
27
  import { openProbeProgram, probeDeepFindings } from './check-deep.js';
28
28
  import { pairFieldReports } from './check-fields.js';
29
+ import { pairDispatchUnions } from './check-union.js';
29
30
  function runProcess(command, args, cwd) {
30
31
  return new Promise((resolve, reject) => {
31
32
  const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
@@ -262,6 +263,9 @@ export async function runCheck(opts, onProgress) {
262
263
  // incompatible (carrick-tools/carrick-cloud#1118). It names what the verdict
263
264
  // is about; it never decides one.
264
265
  const fieldsByPair = pairFieldReports(probeProgram, probing);
266
+ // And which pairs compared a union the expected side takes only in part,
267
+ // the rest failing by nothing but the weak-type check (carrick#1995).
268
+ const unionsByPair = pairDispatchUnions(probeProgram, probing);
265
269
  const verdicts = sortVerdicts([
266
270
  ...probing.map((plan) => classifyPair({
267
271
  plan,
@@ -270,6 +274,7 @@ export async function runCheck(opts, onProgress) {
270
274
  scrubCtx,
271
275
  deepFindings: deepByPair.get(plan.pairId),
272
276
  fieldReport: fieldsByPair.get(plan.pairId),
277
+ dispatchUnion: unionsByPair.get(plan.pairId),
273
278
  rawTextSide: rawTextSideOf(plan, aliasRecords),
274
279
  })),
275
280
  ...preGated,
@@ -256,7 +256,8 @@ export function captureStub(opts) {
256
256
  }
257
257
  // ---- Phase A: analysis program over placeholder entry + anchor sources ----
258
258
  let resolved;
259
- const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath, guard };
259
+ const progress = opts.onProgress ?? (() => { });
260
+ const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath, guard, progress };
260
261
  try {
261
262
  resolved = ownerGroups && emitProject
262
263
  ? resolveAnchorsByOwner(opts, ownerGroups, emitProject, analysisCtx, errors)
@@ -277,14 +278,9 @@ export function captureStub(opts) {
277
278
  }
278
279
  const scratch = WriteGuard.scratch('carrick-capture-v2-');
279
280
  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;
281
+ let written;
287
282
  try {
283
+ progress('emit', 'emitting declarations');
288
284
  guard.writeFile(entryPath, entryLines.join('\n') + '\n');
289
285
  const emitOptions = {
290
286
  ...parsed.options,
@@ -300,35 +296,13 @@ export function captureStub(opts) {
300
296
  outDir: staging,
301
297
  rootDir: entryDir,
302
298
  };
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
- }
299
+ written = emitDeclarations({
300
+ rootNames: [entryPath, ...augmentationSources],
301
+ options: emitOptions,
302
+ host: deno?.host(emitOptions),
303
+ staging,
304
+ entryDir,
305
+ });
332
306
  }
333
307
  catch (err) {
334
308
  return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
@@ -338,6 +312,9 @@ export function captureStub(opts) {
338
312
  guard.unlink(entryPath);
339
313
  scratch.guard.remove(staging);
340
314
  }
315
+ const { emitted, declarationSources, sourceByEmitted } = written;
316
+ const emitPartial = written.partial;
317
+ errors.push(...written.diagnostics);
341
318
  // ---- Partial-emit recovery ----
342
319
  // The corpus-2 notifications-svc shape: one file's declaration emit was
343
320
  // skipped but the rest of the tree emitted fine. Keep the tree; demote any
@@ -490,6 +467,7 @@ export function captureStub(opts) {
490
467
  target: parsed.options.target !== undefined ? ts.ScriptTarget[parsed.options.target] : undefined,
491
468
  }, null, 2) + '\n');
492
469
  // ---- Capture-time self-check (per-alias closure attribution) ----
470
+ progress('self-check', 'checking the stub');
493
471
  const aliases = selfCheckStub({
494
472
  guard: stubGuard,
495
473
  stubDir,
@@ -526,6 +504,58 @@ export function captureStub(opts) {
526
504
  errors,
527
505
  };
528
506
  }
507
+ /**
508
+ * Phase B: build the program of the final entry and emit its declarations.
509
+ *
510
+ * A function of its own so that its program dies with it (carrick#1916). The
511
+ * program is as large as the service, and nothing after the emit reads it:
512
+ * what the capture needs is the text returned here. While the emit ran inside
513
+ * `captureStub`, that one long function's frame went on holding the program
514
+ * for as long as the capture ran, so the self-check loaded its own program
515
+ * beside it; on a 2,345-file service that was 1,290 MB of a 2,741 MB peak.
516
+ *
517
+ * The caller builds the options and the host. A host is held by its program
518
+ * and holds none itself, so the caller keeping one keeps no program alive.
519
+ *
520
+ * Throws when the emit was skipped and wrote nothing.
521
+ */
522
+ function emitDeclarations(args) {
523
+ const emitted = new Map();
524
+ const declarationSources = new Map();
525
+ const sourceByEmitted = new Map();
526
+ const program = ts.createProgram(args.rootNames, args.options, args.host);
527
+ const emitResult = program.emit(undefined, (fileName, text, _bom, _error, sources) => {
528
+ emitted.set(fileName, text);
529
+ if (sources?.[0])
530
+ sourceByEmitted.set(path.relative(args.staging, fileName).split(path.sep).join('/'), sources[0].fileName);
531
+ }, undefined,
532
+ /* emitOnlyDtsFiles */ true);
533
+ // emitSkipped is PER-PROGRAM even when only one file's declaration emit
534
+ // failed (e.g. TS4023 from a hand-rolled ambient stub shadowing a real
535
+ // package): every other file's .d.ts was still written to the callback.
536
+ // Fail wholesale only when nothing at all emitted; otherwise keep the
537
+ // emitted subset and demote exactly the aliases it cannot support.
538
+ if (emitResult.emitSkipped && emitted.size === 0) {
539
+ throw new Error('declaration emit was skipped');
540
+ }
541
+ for (const sourceFile of program.getSourceFiles()) {
542
+ if (!sourceFile.isDeclarationFile)
543
+ continue;
544
+ const abs = path.resolve(sourceFile.fileName);
545
+ const rel = path.relative(args.entryDir, abs).split(path.sep).join('/');
546
+ if (rel.startsWith('..') || rel.includes('node_modules/'))
547
+ continue;
548
+ declarationSources.set(rel, sourceFile.getFullText());
549
+ sourceByEmitted.set(rel, abs);
550
+ }
551
+ return {
552
+ emitted,
553
+ declarationSources,
554
+ sourceByEmitted,
555
+ partial: emitResult.emitSkipped,
556
+ diagnostics: emitResult.diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')),
557
+ };
558
+ }
529
559
  /**
530
560
  * Partial-emit demotion: demote every anchor whose alias text names, by a
531
561
  * relative or an absolute path, a module the stub will not resolve, and
@@ -655,6 +685,7 @@ function resolveAnchors(opts, parsed, ctx, deno) {
655
685
  }
656
686
  ctx.guard.writeFile(ctx.entryPath, placeholderLines.join('\n') + '\n');
657
687
  try {
688
+ ctx.progress('program', 'building the program the anchors are read in');
658
689
  const anchorSources = [
659
690
  ...new Set(opts.anchors
660
691
  .flatMap((a) => (a.source_file ? [path.join(ctx.repoRoot, a.source_file)] : []))),
@@ -664,6 +695,11 @@ function resolveAnchors(opts, parsed, ctx, deno) {
664
695
  noEmit: true,
665
696
  };
666
697
  const program = ts.createProgram([ctx.entryPath, ...anchorSources, ...(deno?.globals ?? [])], options, deno?.host(options));
698
+ // The checker binds every file of the program when it is first asked for,
699
+ // which the first anchor would otherwise do: asked for here, so that the
700
+ // report below says the program is whole before any anchor is read.
701
+ program.getTypeChecker();
702
+ ctx.progress('anchors', `0 of ${opts.anchors.length}`);
667
703
  const entrySource = program.getSourceFile(ctx.entryPath);
668
704
  const placeholders = new Map();
669
705
  if (entrySource) {
@@ -696,13 +732,17 @@ function resolveAnchors(opts, parsed, ctx, deno) {
696
732
  siblingSymbolSpecs.set(anchor.symbol_name, entryRelativeSpecifier(ctx.entryDir, ctx.repoRoot, anchor.source_file, resolveFromEntry));
697
733
  }
698
734
  }
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
- }));
735
+ return opts.anchors.map((request, index) => {
736
+ const anchor = resolveAnchor(program, request, {
737
+ repoRoot: ctx.repoRoot,
738
+ entryDir: ctx.entryDir,
739
+ placeholder: placeholders.get(request.alias),
740
+ siblingSymbolSpecs,
741
+ resolveFromEntry,
742
+ });
743
+ ctx.progress('anchors', `${index + 1} of ${opts.anchors.length}`);
744
+ return anchor;
745
+ });
706
746
  }
707
747
  finally {
708
748
  if (fs.existsSync(ctx.entryPath))
@@ -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).