carrick 0.3.104 → 0.3.105

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.104",
3
+ "version": "0.3.105",
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.104",
62
- "@carrick-tools/cli-darwin-x64": "0.3.104",
63
- "@carrick-tools/cli-linux-arm64": "0.3.104",
64
- "@carrick-tools/cli-linux-x64": "0.3.104",
65
- "@carrick-tools/cli-win32-x64": "0.3.104"
61
+ "@carrick-tools/cli-darwin-arm64": "0.3.105",
62
+ "@carrick-tools/cli-darwin-x64": "0.3.105",
63
+ "@carrick-tools/cli-linux-arm64": "0.3.105",
64
+ "@carrick-tools/cli-linux-x64": "0.3.105",
65
+ "@carrick-tools/cli-win32-x64": "0.3.105"
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.104",
4
+ "version": "0.3.105",
5
5
  "author": {
6
6
  "name": "Carrick",
7
7
  "email": "hello@carrick.tools"
@@ -49,6 +49,12 @@ export interface ResolvedAnchor {
49
49
  * anchor's type holds no unresolved placeholder.
50
50
  */
51
51
  unresolved?: UnresolvedAtAnchor;
52
+ /**
53
+ * carrick#1446: set on a demotion whose alias text names a module the
54
+ * declaration emit skipped. The text dangles in the emitted tree, which the
55
+ * record states for a literal anchor, whose text is what the index serves.
56
+ */
57
+ namesUnemittedModule?: true;
52
58
  }
53
59
  /** Repo-root-relative source file -> extensionless specifier from entryDir. */
54
60
  export declare function entryRelativeSpecifier(entryDir: string, repoRoot: string, sourceFile: string): string;
@@ -185,10 +185,33 @@ export function resolveAnchor(program, request, args) {
185
185
  }
186
186
  return finishInferAnchor(program, sourceFile, request, param, args.placeholder, undefined, args.repoRoot);
187
187
  }
188
- let located = locateNode(sourceFile, request);
189
- if (!located) {
188
+ const found = locate(sourceFile, request);
189
+ if (!found) {
190
190
  return demote(locatorFailureReason(request));
191
191
  }
192
+ // carrick#1785: the line fallback takes the first expression on the line,
193
+ // and a declaration's NAME is an expression to `ts.isExpression`. On a line
194
+ // that declares something, that name is all the fallback finds, and its type
195
+ // is the declared entity's own (a whole alias, a function, a class), never a
196
+ // payload. The line names the declaration, so the anchor abstains: an
197
+ // abstain, not a demotion, because the backfill has nothing better to
198
+ // re-anchor it with (carrick#766). It does not walk on to the next node
199
+ // either, which on a function's line is its first parameter.
200
+ if (found.by === 'line') {
201
+ const declaration = declarationNamedBy(found.node);
202
+ if (declaration) {
203
+ return {
204
+ request,
205
+ aliasText: 'unknown',
206
+ serialization: 'structural_fallback',
207
+ abstainReason: `the line fallback resolved the name of ` +
208
+ `${describeNode(sourceFile, request.source_file, declaration)}; a ` +
209
+ `declaration's name is not a payload, so the anchor abstains rather ` +
210
+ `than publish the declared entity's own type`,
211
+ };
212
+ }
213
+ }
214
+ let located = found.node;
192
215
  // carrick#1162: a serialised body is the JSON of its argument. The call's own
193
216
  // `string` result is never the payload's contract, and publishing it reads
194
217
  // incompatible against every object-typed counterparty.
@@ -252,17 +275,18 @@ function finishInferAnchor(program, sourceFile, request, located, placeholder, r
252
275
  // here.
253
276
  //
254
277
  // A line alone names nothing. `firstExpressionOnLine` picks whatever comes
255
- // first on the line, which on a re-export statement is the first exported
256
- // binding. When THAT resolves to a top type the capture holds no payload and
257
- // no type, and `export type <alias> = any;` states "a type was inferred and
258
- // it collapsed" — a claim the scan cannot back. The honest word is `unknown`
259
- // ("no contract stated here"), with the node the line resolved as the reason.
278
+ // first on the line. When THAT resolves to a top type the capture holds no
279
+ // payload and no type, and `export type <alias> = any;` states "a type was
280
+ // inferred and it collapsed" — a claim the scan cannot back. The honest word
281
+ // is `unknown` ("no contract stated here"), with the node the line resolved
282
+ // as the reason.
260
283
  //
261
284
  // Live shape: a route whose handlers are built by a framework factory and
262
285
  // re-exported at the bottom of the file. Both of the file's operations
263
- // anchor at the export statement, the v1 walk abstains there (carrick#771),
264
- // and the alias falls to this line-only locator, which resolves the first
265
- // exported binding's identifier.
286
+ // anchor at the export statement and the v1 walk abstains there
287
+ // (carrick#771). The export line itself now abstains earlier, as a
288
+ // declaration's name (carrick#1785); this guard still holds for a line whose
289
+ // first node is a value that decayed, such as the factory call's binding.
266
290
  if (isTopType(type) && isLineOnly(request)) {
267
291
  return {
268
292
  request,
@@ -914,21 +938,62 @@ function locatorFailureReason(request) {
914
938
  * else the first expression starting on line_number.
915
939
  */
916
940
  export function locateNode(sourceFile, request) {
941
+ return locate(sourceFile, request)?.node;
942
+ }
943
+ /** The node `locateNode` resolves, and which of its three locators found it. */
944
+ function locate(sourceFile, request) {
917
945
  if (request.span_start !== undefined && request.span_end !== undefined) {
918
946
  const bySpan = tightestCoveringNode(sourceFile, request.span_start, request.span_end);
919
947
  if (bySpan)
920
- return bySpan;
948
+ return { node: bySpan, by: 'span' };
921
949
  }
922
950
  if (request.expression_text) {
923
951
  const byText = nodeByExpressionText(sourceFile, request.expression_text, request.line_number);
924
952
  if (byText)
925
- return byText;
953
+ return { node: byText, by: 'text' };
926
954
  }
927
955
  if (request.line_number !== undefined) {
928
- return firstExpressionOnLine(sourceFile, request.line_number);
956
+ const byLine = firstExpressionOnLine(sourceFile, request.line_number);
957
+ if (byLine)
958
+ return { node: byLine, by: 'line' };
929
959
  }
930
960
  return undefined;
931
961
  }
962
+ /**
963
+ * The declaration `node` is the name of, when it names one (carrick#1785): a
964
+ * type alias, interface, class, function, method, property, accessor, enum or
965
+ * enum member, namespace, or an import or export binding (whose `propertyName`
966
+ * names the binding too: `export { a as b }`).
967
+ *
968
+ * Not a binding whose name is also the value read at that position: a
969
+ * destructured element and a shorthand property keep resolving. A variable,
970
+ * a parameter and a property assignment never reach here, because each is a
971
+ * preferred target the line walk takes before its name.
972
+ */
973
+ function declarationNamedBy(node) {
974
+ const parent = node.parent;
975
+ if (!parent)
976
+ return undefined;
977
+ // A specifier's only children are its names.
978
+ if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent))
979
+ return parent;
980
+ const named = ts.isTypeAliasDeclaration(parent) ||
981
+ ts.isInterfaceDeclaration(parent) ||
982
+ ts.isClassDeclaration(parent) ||
983
+ ts.isFunctionDeclaration(parent) ||
984
+ ts.isMethodDeclaration(parent) ||
985
+ ts.isMethodSignature(parent) ||
986
+ ts.isPropertyDeclaration(parent) ||
987
+ ts.isPropertySignature(parent) ||
988
+ ts.isGetAccessorDeclaration(parent) ||
989
+ ts.isSetAccessorDeclaration(parent) ||
990
+ ts.isEnumDeclaration(parent) ||
991
+ ts.isEnumMember(parent) ||
992
+ ts.isModuleDeclaration(parent) ||
993
+ ts.isImportClause(parent) ||
994
+ ts.isNamespaceImport(parent);
995
+ return named && parent.name === node ? parent : undefined;
996
+ }
932
997
  function isPreferredTarget(node) {
933
998
  return (ts.isExpression(node) ||
934
999
  ts.isVariableDeclaration(node) ||
@@ -251,6 +251,26 @@ export interface CaptureAliasRecord {
251
251
  * absent when there are none.
252
252
  */
253
253
  undeclared_names?: string[];
254
+ /**
255
+ * Every position at which this alias's type, as the emitted tree states it,
256
+ * holds TypeScript's unresolved-reference placeholder (carrick#1446): `''`
257
+ * when the alias's own type does not resolve, member paths otherwise, in the
258
+ * deep walk's notation, sorted by path. The compiler prints the placeholder
259
+ * as the name it could not follow, so the text reads like a type while every
260
+ * reader of the tree reads `any` there.
261
+ *
262
+ * Read on the self-check program, where the producer's installed packages
263
+ * resolve, so a name only a missing install leaves unresolved is listed only
264
+ * on a bare checkout. A literal anchor demoted because its text names a
265
+ * module the emit skipped is listed at its root: that text is what the
266
+ * index serves for it, and it names a module the tree does not hold.
267
+ *
268
+ * Kept apart from `any_provenance` on purpose: the check phase pre-gates
269
+ * on `any_provenance[0]`, and this list is what the index tells a reader,
270
+ * not a verdict. The scanner joins it onto the manifest entry beside the
271
+ * entry's other provenance. Absent when every position resolves.
272
+ */
273
+ unresolved_in_tree?: TypeProvenance[];
254
274
  }
255
275
  /** Aggregate fidelity metric, emitted per capture (one service). */
256
276
  export interface CaptureFidelity {
@@ -295,7 +315,8 @@ export interface CaptureStubOptions {
295
315
  /**
296
316
  * The scanned repo's root, the upper bound of the search for a tsconfig
297
317
  * above `repoRoot` when none is named (carrick#1776). Without it only
298
- * `repoRoot` is searched.
318
+ * `repoRoot` is searched. It is a protected tree as well: `outDir` may
319
+ * lie inside it only beneath a `.carrick` directory (carrick#1768).
299
320
  */
300
321
  scanRoot?: string;
301
322
  }
@@ -431,8 +452,6 @@ export interface CheckResult {
431
452
  * missing (soundness over availability — pinned design, Check step 2). */
432
453
  isolation: 'pnpm' | 'unavailable';
433
454
  install_ok: boolean;
434
- /** Scrubbed install-failure summary when install_ok is false. */
435
- install_error?: string;
436
455
  ts_version: string;
437
456
  /** Verdicts, sorted by pair_id for byte-stable output. */
438
457
  verdicts: CheckVerdict[];
@@ -12,6 +12,7 @@
12
12
  * IsUnknown/IsNever gate fired (TS2344) -> unverifiable
13
13
  * IsVoid gate fired (TS2344) -> unverifiable (no body read)
14
14
  * IsFormBody gate fired (TS2344) -> unverifiable (form-encoded body)
15
+ * IsByteBody gate fired (TS2344) -> unverifiable (bytes, even agreeing)
15
16
  * assignment-class error -> incompatible
16
17
  * no diagnostics -> compatible [lowest precedence]
17
18
  *
@@ -12,6 +12,7 @@
12
12
  * IsUnknown/IsNever gate fired (TS2344) -> unverifiable
13
13
  * IsVoid gate fired (TS2344) -> unverifiable (no body read)
14
14
  * IsFormBody gate fired (TS2344) -> unverifiable (form-encoded body)
15
+ * IsByteBody gate fired (TS2344) -> unverifiable (bytes, even agreeing)
15
16
  * assignment-class error -> incompatible
16
17
  * no diagnostics -> compatible [lowest precedence]
17
18
  *
@@ -172,19 +173,18 @@ export function classifyPair(input) {
172
173
  };
173
174
  }
174
175
  // 4c. A body of bytes (carrick#1793): a blob, a buffer or a stream has no JSON
175
- // shape, so a MISMATCH between the containers two sides hold the bytes
176
- // in (a `Uint8Array` sent, read with `.blob()`) is not a drift. It
177
- // overrides a mismatch only: bytes that assign to bytes (a stream sent
178
- // where a stream is read) agree, and that verdict stands. When both
179
- // sides are bytes the sent side is named, whatever order the
180
- // diagnostics came in.
176
+ // shape, and the check cannot read a file's content. So a pair with
177
+ // bytes on either side states no contract, whether the two sides
178
+ // mismatch (a `Uint8Array` sent, read with `.blob()`) or agree (a stream
179
+ // sent where a stream is read, carrick#1812). When both sides are bytes
180
+ // the sent side is named, whatever order the diagnostics came in.
181
181
  const firedGates = new Set(gateDiags.map((d) => plan.gateLines.get(d.line)));
182
182
  const bytesGate = firedGates.has('sent:bytes')
183
183
  ? 'sent:bytes'
184
184
  : firedGates.has('expected:bytes')
185
185
  ? 'expected:bytes'
186
186
  : undefined;
187
- const bytesVerdict = () => {
187
+ if (bytesGate) {
188
188
  const { side } = sideForGate(bytesGate, plan);
189
189
  return {
190
190
  ...base,
@@ -193,7 +193,7 @@ export function classifyPair(input) {
193
193
  diagnostic: `the ${side} body is bytes (a blob, a buffer or a stream), which has no JSON shape to compare with the other side.`,
194
194
  ...notAFact(`the ${side} body is bytes`, side),
195
195
  };
196
- };
196
+ }
197
197
  // 5. Assignment-class error on the DECISIVE assignment line -> incompatible.
198
198
  //
199
199
  // On an `http` pair that line is the JSON wire assignment, not the declared
@@ -206,8 +206,6 @@ export function classifyPair(input) {
206
206
  const decisiveLine = decisiveAssignmentLine(plan);
207
207
  const assignDiag = probeDiags.find((d) => d.line === decisiveLine && ASSIGNMENT_CODES.has(d.code));
208
208
  if (assignDiag) {
209
- if (bytesGate)
210
- return bytesVerdict();
211
209
  const text = scrubDiagnostic(assignDiag.message, scrubCtx, plan.sentEndpoint.alias, plan.expectedEndpoint.alias);
212
210
  return {
213
211
  ...base,
@@ -243,8 +241,6 @@ export function classifyPair(input) {
243
241
  if (wireOther) {
244
242
  const declaredMismatch = probeDiags.find((d) => d.line === plan.assignmentLine && ASSIGNMENT_CODES.has(d.code));
245
243
  if (declaredMismatch) {
246
- if (bytesGate)
247
- return bytesVerdict();
248
244
  return {
249
245
  ...base,
250
246
  bucket: 'incompatible',
@@ -29,6 +29,7 @@
29
29
  * Seam: node builtins + `typescript` + this bundle only.
30
30
  */
31
31
  import ts from 'typescript';
32
+ import { memberPath } from './member-name.js';
32
33
  /**
33
34
  * Cap on named fields. A mismatch with more differing members than this is
34
35
  * better described as two unrelated shapes than as a list, and the text says
@@ -172,7 +173,7 @@ function walk(sent, expected, path, depth, ctx) {
172
173
  * makes a sender-only member worth naming beside it. */
173
174
  let absent = 0;
174
175
  for (const [name, expectedProp] of expectedProps) {
175
- const at = join(path, name);
176
+ const at = memberPath(path, expectedProp, checker);
176
177
  const sentProp = sentProps.get(name);
177
178
  const supplied = ctx.serverSupplied.has(name);
178
179
  if (!sentProp) {
@@ -226,15 +227,12 @@ function walk(sent, expected, path, depth, ctx) {
226
227
  // producer returns — nothing is absent and nothing is named.
227
228
  if (absent === 0)
228
229
  return;
229
- for (const [name] of sentProps) {
230
+ for (const [name, sentProp] of sentProps) {
230
231
  if (expectedProps.has(name))
231
232
  continue;
232
- ctx.found.push({ path: join(path, name), nature: 'extra_in_sent' });
233
+ ctx.found.push({ path: memberPath(path, sentProp, checker), nature: 'extra_in_sent' });
233
234
  }
234
235
  }
235
- function join(path, name) {
236
- return path === '' ? name : `${path}.${name}`;
237
- }
238
236
  function isOptional(symbol) {
239
237
  return (symbol.flags & ts.SymbolFlags.Optional) !== 0;
240
238
  }
@@ -155,8 +155,8 @@ export function buildProbe(spec, packageOf) {
155
155
  // them in, so a route sending a `Uint8Array` read with `.blob()` is not a
156
156
  // mismatch, and neither is the same route read as text. Either side, either
157
157
  // half: a file upload states no fields a declared body could be compared
158
- // with. The classifier lets this gate override a mismatch only, so bytes
159
- // that assign to bytes still read compatible. `null`/`undefined` are
158
+ // with. Bytes that assign to bytes are not a contract either: the check
159
+ // cannot read a file's content (carrick#1812). `null`/`undefined` are
160
160
  // dropped first, so `Blob | null` still gates, while a bare `null`,
161
161
  // `undefined` or `never` leaves nothing to test.
162
162
  if (spec.protocol === 'http') {
@@ -182,6 +182,12 @@ export function buildProbe(spec, packageOf) {
182
182
  // or several (ambiguous envelope, or a bare payload whose own fields
183
183
  // include more than one object-shaped property) keep the whole type —
184
184
  // the unwrap never fires on anything but an unambiguous envelope;
185
+ // - a consumer that names a field only the outer object has (not the
186
+ // payload under its one object-shaped property) reads that object, so
187
+ // it keeps the whole type (carrick#1764): a bare payload with one
188
+ // object-shaped property is not an envelope around it. A field both
189
+ // declare says nothing either way, and neither does `__typename`, which
190
+ // the server puts on every object (see below);
185
191
  // - `GqlObjLike` can never select `any`/`unknown`/`never` (top/bottom
186
192
  // types fail both its array and its object arm), and the comparand is
187
193
  // re-gated below anyway — the v2 port of v1's comparand re-guard, so
@@ -191,6 +197,10 @@ export function buildProbe(spec, packageOf) {
191
197
  push(`type GqlPayloadKeys<T> = { [K in keyof T]-?: [GqlObjLike<T[K]>] extends [true] ? K : never }[keyof T];`);
192
198
  push(`type GqlSingleKey<K> = [K] extends [never] ? never : ([GqlIsUnion<K>] extends [false] ? K : never);`);
193
199
  push(`type GqlPayloadOf<S> = [S] extends [readonly unknown[]] ? never : [GqlIsUnion<S>] extends [true] ? never : [S] extends [object] ? ([GqlSingleKey<GqlPayloadKeys<S>>] extends [never] ? never : S[GqlSingleKey<GqlPayloadKeys<S>> & keyof S]) : never;`);
200
+ // The field names a reading declares: the keys of every object member, so
201
+ // a nullable or union consumer names what each member names.
202
+ push(`type GqlNames<T> = T extends object ? keyof T : never;`);
203
+ push(`type GqlReadsOuter<S, E> = [Extract<Exclude<GqlNames<S>, GqlNames<GqlPayloadOf<S>>>, Exclude<GqlNames<E>, '__typename'>>] extends [never] ? false : true;`);
194
204
  // carrick#1759: the server supplies `__typename` on every object a
195
205
  // selection asks for, so the consumer's `__typename` is read as OPTIONAL,
196
206
  // at any depth. It keeps its declared type: a producer that states a
@@ -208,14 +218,14 @@ export function buildProbe(spec, packageOf) {
208
218
  // nothing observable, so a consumer with no `__typename` to relax is
209
219
  // judged, and named in the headline, exactly as before.
210
220
  //
211
- // The envelope short-circuit below tests the RELAXED consumer: a bare
212
- // payload with one object-shaped property would otherwise fail it on
213
- // `__typename` alone and unwrap onto that property.
221
+ // The envelope short-circuit below tests the RELAXED consumer: a consumer
222
+ // that names only fields the payload shares would otherwise fail it on
223
+ // `__typename` alone and unwrap onto that payload.
214
224
  push(`type GqlDepth = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];`);
215
225
  push(`type GqlTypenameParts<T, D extends number> = { [K in keyof T as K extends '__typename' ? never : K]: GqlTypenameOptional<T[K], GqlDepth[D]> } & { [K in keyof T as K extends '__typename' ? K : never]?: T[K] };`);
216
226
  push(`type GqlTypenameOptional<T, D extends number = 16> = [D] extends [never] ? T : T extends (...args: any[]) => any ? T : T extends object ? ('__typename' extends keyof T ? { [K in keyof GqlTypenameParts<T, D>]: GqlTypenameParts<T, D>[K] } : { [K in keyof T]: GqlTypenameOptional<T[K], GqlDepth[D]> }) : T;`);
217
227
  push(`type GqlExpected = [GqlTypenameOptional<Expected>] extends [Expected] ? Expected : GqlTypenameOptional<Expected>;`);
218
- push(`type GqlComparand = [Sent] extends [GqlExpected] ? Sent : ([GqlPayloadOf<Sent>] extends [never] ? Sent : GqlPayloadOf<Sent>);`);
228
+ push(`type GqlComparand = [Sent] extends [GqlExpected] ? Sent : ([GqlPayloadOf<Sent>] extends [never] ? Sent : [GqlReadsOuter<Sent, Expected>] extends [true] ? Sent : GqlPayloadOf<Sent>);`);
219
229
  gateLines.set(push(`type _G_comparand_any = Assert<Not<IsAny<GqlComparand>>>;`), 'sent:any');
220
230
  gateLines.set(push(`type _G_comparand_unknown = Assert<Not<IsUnknown<GqlComparand>>>;`), 'sent:unknown');
221
231
  gateLines.set(push(`type _G_comparand_never = Assert<Not<IsNever<GqlComparand>>>;`), 'sent:never');
@@ -16,6 +16,9 @@
16
16
  export interface ScrubContext {
17
17
  /** Absolute path of the scratch workspace root (fully removed from output). */
18
18
  workspaceRoot: string;
19
+ /** The same root with symlinks resolved, as a tool that prints real paths
20
+ * names it (pnpm, under a macOS temp dir). Absent when there is no tree. */
21
+ workspaceRealRoot?: string;
19
22
  /** Workspace package dir (== sanitized service) -> the @carrick/<dir> label. */
20
23
  packageLabelOf: (packageDir: string) => string | undefined;
21
24
  }
@@ -46,10 +46,14 @@ export function scrubPaths(text, ctx) {
46
46
  let out = text.replace(IMPORT_PATH_RE, (_m, p1) => {
47
47
  return `import("${labelForImportPath(p1, ctx)}")`;
48
48
  });
49
- // Defensive: no raw temp path may survive anywhere in the message.
50
- if (ctx.workspaceRoot) {
51
- out = out.split(ctx.workspaceRoot).join('');
52
- out = out.split(ctx.workspaceRoot.split('/').join('\\')).join('');
49
+ // Defensive: no raw temp path may survive anywhere in the message. The real
50
+ // root goes first: the given root can be its suffix (`/private/var/...`
51
+ // against `/var/...`), and stripping that first would strand the prefix.
52
+ for (const root of [ctx.workspaceRealRoot, ctx.workspaceRoot]) {
53
+ if (!root)
54
+ continue;
55
+ out = out.split(root).join('');
56
+ out = out.split(root.split('/').join('\\')).join('');
53
57
  }
54
58
  return out;
55
59
  }
@@ -103,15 +103,18 @@ export async function runCheck(opts, onProgress) {
103
103
  // to a flat npm install that manufactures nominal false-incompatibles. Fail
104
104
  // explicitly instead (design Check step 2).
105
105
  if (!fs.existsSync(pnpmPath)) {
106
- const planned = planPairs(opts, (s) => `@carrick/${s}`);
106
+ // Capture-decay verdicts stand without isolation too (carrick#1833).
107
+ const { preGated, probing } = preGate(planPairs(opts, (s) => `@carrick/${s}`), readStubAliasRecords(opts.stubs));
107
108
  return {
108
109
  success: false,
109
110
  workspace_dir: '',
110
111
  isolation: 'unavailable',
111
112
  install_ok: false,
112
- install_error: 'vendored pnpm not found; type isolation is unavailable',
113
113
  ts_version: tsVersion,
114
- verdicts: sortVerdicts(unverifiableAll(planned, 'isolation:unavailable', 'type isolation unavailable (pnpm missing); compatibility cannot be verified.')),
114
+ verdicts: sortVerdicts([
115
+ ...unverifiableAll(probing, 'isolation:unavailable', 'type isolation unavailable (pnpm missing); compatibility cannot be verified.'),
116
+ ...preGated,
117
+ ]),
115
118
  degraded_services: opts.stubs.map((s) => ({
116
119
  service_name: s.service_name,
117
120
  reason: 'isolation unavailable',
@@ -126,6 +129,7 @@ export async function runCheck(opts, onProgress) {
126
129
  });
127
130
  const scrubCtx = {
128
131
  workspaceRoot: ws.workspaceDir,
132
+ workspaceRealRoot: fs.realpathSync(ws.workspaceDir),
129
133
  packageLabelOf: ws.packageLabelOf,
130
134
  };
131
135
  // Generate probes. A pair naming a service with no stub is unverifiable.
@@ -154,61 +158,25 @@ export async function runCheck(opts, onProgress) {
154
158
  const spec = opts.pairs.find((p) => p.pair_key === v.pair_key);
155
159
  v.pair_id = fnvOfSpec(spec);
156
160
  }
157
- // Capture-time deep-decay pre-gate (adversarial-review finding 1): a
158
- // member-level `any`/`unknown` recorded by the capture self-check with no
159
- // failing-external explanation. The probe gates below are WHOLE-type only
160
- // — `{ orderId: string; metadata: any }` sails through IsAny and the
161
- // assignment compiles clean — so such pairs must never reach a probe.
162
- // `any` routes to gate_caught_baked_any, `unknown` to unverifiable; both
163
- // read as None downstream, never compatible.
164
- const aliasRecords = readStubAliasRecords(opts.stubs);
165
- const preGated = [];
166
- const probing = [];
167
- for (const plan of plans) {
168
- const hit = deepDecayOf(plan, aliasRecords);
169
- if (!hit) {
170
- probing.push(plan);
171
- continue;
172
- }
173
- preGated.push({
174
- pair_id: plan.pairId,
175
- pair_key: plan.spec.pair_key,
176
- // `any` is a confirmed baked top type -> gate_caught_baked_any; `unknown`
177
- // and `budget_exhausted` (a subtree the capture walk could not finish)
178
- // are "cannot verify" -> unverifiable. All read as None downstream.
179
- bucket: hit.kind === 'any' ? 'gate_caught_baked_any' : 'unverifiable',
180
- gate: `capture:${hit.side}:${hit.kind}`,
181
- diagnostic: hit.kind === 'budget_exhausted'
182
- ? `the ${hit.side} type is too deep or wide to verify within the ` +
183
- `capture budget (at '${hit.path}'); compatibility cannot be ` +
184
- `verified — abstaining so a buried 'any' can never read compatible.`
185
- : `the ${hit.side} type carries '${hit.kind}' at '${hit.path}' from ` +
186
- `capture; compatibility cannot be verified (a partially-unresolved ` +
187
- `type would let an arbitrary shape read compatible).`,
188
- codes: [],
189
- resolved: false,
190
- unresolved_side: hit.side,
191
- unresolved_reason: hit.kind === 'budget_exhausted'
192
- ? `the ${hit.side} type is too deep or wide to verify at '${hit.path}'`
193
- : `the ${hit.side} type carries '${hit.kind}' at '${hit.path}'`,
194
- notes: [],
195
- });
196
- }
161
+ const { preGated, probing } = preGate(plans, readStubAliasRecords(opts.stubs));
197
162
  writeProbes(ws, probing);
198
163
  const errors = [];
199
164
  const degraded = [];
200
165
  // ---- Install (async, off the event loop) --------------------------------
201
166
  progress('installing', 'installing pinned dependencies');
202
167
  const install = await runProcess(pnpmPath, ['install'], ws.workspaceDir);
203
- const installOk = install.code === 0;
204
- let installError;
205
- if (!installOk) {
206
- installError = scrubPaths((install.stderr || install.stdout).trim().slice(0, 2000), scrubCtx);
168
+ if (install.code !== 0) {
169
+ const installError = scrubPaths((install.stderr || install.stdout).trim().slice(0, 2000), scrubCtx);
170
+ // `errors` says why the run failed (carrick#1821); each pair the install
171
+ // stopped says it too, because the client reads every pair's own verdict
172
+ // on a failed check (carrick#1833).
173
+ errors.push(`workspace dependency install failed${installError ? `: ${installError}` : ''}`);
207
174
  for (const s of opts.stubs) {
208
175
  degraded.push({ service_name: s.service_name, reason: 'workspace install failed' });
209
176
  }
210
177
  const verdicts = sortVerdicts([
211
- ...unverifiableAll(probing, 'install:failed', 'workspace dependency install failed; compatibility cannot be verified.'),
178
+ ...unverifiableAll(probing, 'install:failed', 'workspace dependency install failed; compatibility cannot be verified.' +
179
+ (installError ? ` Installer output: ${installError}` : '')),
212
180
  // Capture-decay verdicts stand regardless of the install outcome.
213
181
  ...preGated,
214
182
  ...unresolved,
@@ -220,7 +188,6 @@ export async function runCheck(opts, onProgress) {
220
188
  workspace_dir: cleanup ? '' : ws.workspaceDir,
221
189
  isolation: 'pnpm',
222
190
  install_ok: false,
223
- install_error: installError,
224
191
  ts_version: tsVersion,
225
192
  verdicts,
226
193
  degraded_services: degraded,
@@ -249,7 +216,9 @@ export async function runCheck(opts, onProgress) {
249
216
  });
250
217
  }
251
218
  const verdicts = sortVerdicts([
252
- ...unverifiableAll(probing, 'tsc:abnormal-termination', 'the type checker terminated abnormally; compatibility cannot be verified.'),
219
+ ...unverifiableAll(probing, 'tsc:abnormal-termination', `the type checker terminated abnormally (exit code ${tsc.code ?? 'null'}); ` +
220
+ 'compatibility cannot be verified.' +
221
+ (excerpt ? ` Compiler output: ${excerpt}` : '')),
253
222
  ...preGated,
254
223
  ...unresolved,
255
224
  ]);
@@ -341,6 +310,51 @@ function readStubAliasRecords(stubs) {
341
310
  }
342
311
  return byService;
343
312
  }
313
+ /**
314
+ * Capture-time deep-decay pre-gate (adversarial-review finding 1): a
315
+ * member-level `any`/`unknown` recorded by the capture self-check with no
316
+ * failing-external explanation. The probe gates are WHOLE-type only —
317
+ * `{ orderId: string; metadata: any }` sails through IsAny and the assignment
318
+ * compiles clean — so such pairs must never reach a probe. `any` routes to
319
+ * gate_caught_baked_any, `unknown` to unverifiable; both read as None
320
+ * downstream, never compatible. These verdicts come from the capture alone,
321
+ * so they stand whatever the install or the compiler then does (carrick#1833).
322
+ */
323
+ function preGate(plans, aliasRecords) {
324
+ const preGated = [];
325
+ const probing = [];
326
+ for (const plan of plans) {
327
+ const hit = deepDecayOf(plan, aliasRecords);
328
+ if (!hit) {
329
+ probing.push(plan);
330
+ continue;
331
+ }
332
+ preGated.push({
333
+ pair_id: plan.pairId,
334
+ pair_key: plan.spec.pair_key,
335
+ // `any` is a confirmed baked top type -> gate_caught_baked_any; `unknown`
336
+ // and `budget_exhausted` (a subtree the capture walk could not finish)
337
+ // are "cannot verify" -> unverifiable. All read as None downstream.
338
+ bucket: hit.kind === 'any' ? 'gate_caught_baked_any' : 'unverifiable',
339
+ gate: `capture:${hit.side}:${hit.kind}`,
340
+ diagnostic: hit.kind === 'budget_exhausted'
341
+ ? `the ${hit.side} type is too deep or wide to verify within the ` +
342
+ `capture budget (at '${hit.path}'); compatibility cannot be ` +
343
+ `verified — abstaining so a buried 'any' can never read compatible.`
344
+ : `the ${hit.side} type carries '${hit.kind}' at '${hit.path}' from ` +
345
+ `capture; compatibility cannot be verified (a partially-unresolved ` +
346
+ `type would let an arbitrary shape read compatible).`,
347
+ codes: [],
348
+ resolved: false,
349
+ unresolved_side: hit.side,
350
+ unresolved_reason: hit.kind === 'budget_exhausted'
351
+ ? `the ${hit.side} type is too deep or wide to verify at '${hit.path}'`
352
+ : `the ${hit.side} type carries '${hit.kind}' at '${hit.path}'`,
353
+ notes: [],
354
+ });
355
+ }
356
+ return { preGated, probing };
357
+ }
344
358
  /** First side (producer, then consumer) whose capture recorded a deep decay. */
345
359
  function deepDecayOf(plan, aliasRecords) {
346
360
  for (const side of ['producer', 'consumer']) {
@@ -63,6 +63,9 @@ export declare function findDisqualifyingTopTypes(root: ts.Type, program: ts.Pro
63
63
  * names. A walk that runs out of budget reports what it found before it did.
64
64
  */
65
65
  export declare function findUnresolvedPlaceholders(root: ts.Type, program: ts.Program, checker: ts.TypeChecker, location: ts.Node): string[];
66
+ /** TypeScript's unresolved-reference placeholder: `TypeFlags.Any` with the
67
+ * internal `intrinsicName === 'error'` (stable since TS 1.x; see `anchors.ts`). */
68
+ export declare function isErrorPlaceholder(t: ts.Type): boolean;
66
69
  /** What an anchor's SOURCE program could not resolve (carrick#1164). */
67
70
  export interface UnresolvedAtAnchor {
68
71
  /** Member paths holding the unresolved-reference placeholder. */
@@ -87,3 +90,20 @@ export interface UnresolvedAtAnchor {
87
90
  * one other cause the walk itself can distinguish is its own budget.
88
91
  */
89
92
  export declare function provenanceOf(finding: DeepTopType, unresolved?: UnresolvedAtAnchor): TypeProvenance;
93
+ /**
94
+ * The entry for one position at which the emitted tree holds the
95
+ * unresolved-reference placeholder (carrick#1446): `path` is `''` for the
96
+ * alias's own type. `names` are the identifiers the self-check could not find
97
+ * in this alias's statement and the files it reaches.
98
+ *
99
+ * The cause is `unresolved_import`, the word a reader already has for "a
100
+ * reference here did not resolve"; the detail says where it failed to.
101
+ */
102
+ export declare function unresolvedInTreeProvenance(path: string, names: readonly string[]): TypeProvenance;
103
+ /**
104
+ * The root entry for a literal anchor demoted because its text names a module
105
+ * whose declaration emit was skipped (carrick#1446, carrick#1165): that text is
106
+ * what the index serves for it, and nothing in the emitted tree declares what
107
+ * it imports.
108
+ */
109
+ export declare function unemittedModuleProvenance(): TypeProvenance;