carrick 0.3.115 → 0.3.116

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.115",
3
+ "version": "0.3.116",
4
4
  "carrickCache": {
5
5
  "cache_version": 22,
6
6
  "intent_cache_version": 2
@@ -113,11 +113,11 @@
113
113
  "zod": "^3.23.0"
114
114
  },
115
115
  "optionalDependencies": {
116
- "@carrick-tools/cli-darwin-arm64": "0.3.115",
117
- "@carrick-tools/cli-darwin-x64": "0.3.115",
118
- "@carrick-tools/cli-linux-arm64": "0.3.115",
119
- "@carrick-tools/cli-linux-x64": "0.3.115",
120
- "@carrick-tools/cli-win32-x64": "0.3.115"
116
+ "@carrick-tools/cli-darwin-arm64": "0.3.116",
117
+ "@carrick-tools/cli-darwin-x64": "0.3.116",
118
+ "@carrick-tools/cli-linux-arm64": "0.3.116",
119
+ "@carrick-tools/cli-linux-x64": "0.3.116",
120
+ "@carrick-tools/cli-win32-x64": "0.3.116"
121
121
  },
122
122
  "devDependencies": {
123
123
  "@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.115",
4
+ "version": "0.3.116",
5
5
  "author": {
6
6
  "name": "Carrick",
7
7
  "email": "hello@carrick.tools"
@@ -2,8 +2,23 @@
2
2
  * TypeBundler - the `bundle` action: each requested symbol's declaration text,
3
3
  * read from the init'd ts-morph project.
4
4
  */
5
- import { Project } from 'ts-morph';
5
+ import { Project, SourceFile } from 'ts-morph';
6
6
  import type { SymbolRequest, BundleResult } from './types.js';
7
+ import { type ExpandOrigin } from './type-structural-expander.js';
8
+ /** Where a requested symbol is declared: the file and the name it has there. */
9
+ interface DeclarationSite {
10
+ sourceFile: SourceFile;
11
+ name: string;
12
+ }
13
+ /**
14
+ * Where `name`, as `sourceFile` exports it, is declared (carrick#1605). A name
15
+ * the file declares is its own. Otherwise it is followed the way an import of
16
+ * it is: a model names a type where the consumer imports it from, usually a
17
+ * barrel that only has `export *`.
18
+ */
19
+ export declare function declarationSite(sourceFile: SourceFile, name: string, label: string, origin: ExpandOrigin): DeclarationSite | {
20
+ reason: string;
21
+ };
7
22
  /**
8
23
  * Options for TypeBundler construction (legacy)
9
24
  */
@@ -67,3 +82,4 @@ export declare class TypeBundler {
67
82
  private log;
68
83
  private logError;
69
84
  }
85
+ export {};
@@ -85,7 +85,7 @@ function declaredName(declaration) {
85
85
  * it is: a model names a type where the consumer imports it from, usually a
86
86
  * barrel that only has `export *`.
87
87
  */
88
- function declarationSite(sourceFile, name, label, origin) {
88
+ export function declarationSite(sourceFile, name, label, origin) {
89
89
  if (declaresName(sourceFile, name))
90
90
  return { sourceFile, name };
91
91
  const declarations = exportedDeclarations(sourceFile, name, new Set());
@@ -10,7 +10,7 @@ import { printTypeForDestination, substituteUndeclaredNames, undeclaredNamesIn,
10
10
  import { typeIsOrContainsMachinery } from './machinery.js';
11
11
  import { installedPackageSpecifier } from './installed-package.js';
12
12
  import { realPath } from './service-config.js';
13
- import { statusTableBody } from './status-table.js';
13
+ import { statesNoShape, statusTableBody } from './status-table.js';
14
14
  import { unresolvedAtAnchor, unresolvedSpecifiersReachableFrom, } from './unresolved.js';
15
15
  /**
16
16
  * Repo-root-relative source file -> the specifier the surface entry imports
@@ -320,6 +320,13 @@ function finishInferAnchor(program, sourceFile, request, located, placeholder, r
320
320
  type = recovered;
321
321
  }
322
322
  }
323
+ // carrick#2116: a narrowed `unknown` (the checker's own empty object type)
324
+ // and `object` state no shape, at the root or as a member of the root
325
+ // union. Printed they read `{}` and `object`, and would be judged as a
326
+ // shape that lacks every field. They are read as the `unknown` they are.
327
+ if (statesNoShape(checker, type)) {
328
+ type = checker.getUnknownType();
329
+ }
323
330
  // carrick#766: a LINE-ONLY anchor whose type came back a bare top type has
324
331
  // no evidence on either side, so it must abstain.
325
332
  //
@@ -420,7 +420,12 @@ export interface CheckPairSpec {
420
420
  consumer: CheckPairEndpoint;
421
421
  }
422
422
  /**
423
- * Four-bucket classifier output (pinned decision 7):
423
+ * Classifier output: the four buckets of pinned decision 7, and one more
424
+ * (carrick#1538).
425
+ * - producer_wider: the published sent type mismatches and the handler's
426
+ * return, read with no literal widened, does not. What travels agrees, so
427
+ * it is stored as compatible with the class beside it, and it carries no
428
+ * diagnostic.
424
429
  * - compatible: no diagnostics; the value-level assignment holds.
425
430
  * - incompatible: an assignment-class diagnostic (TS2322/2741/...) — real
426
431
  * compiler text is the report.
@@ -429,7 +434,7 @@ export interface CheckPairSpec {
429
434
  * - gate_caught_baked_any: a side resolved to `any` (the IsAny probe gate
430
435
  * fired) — the backstop that stops a baked-any reading as compatible.
431
436
  */
432
- export type VerdictBucket = 'compatible' | 'incompatible' | 'unverifiable' | 'gate_caught_baked_any';
437
+ export type VerdictBucket = 'compatible' | 'incompatible' | 'unverifiable' | 'gate_caught_baked_any' | 'producer_wider';
433
438
  export interface CheckVerdict {
434
439
  /** Deterministic FNV-1a hash of the pair (never a temp path). */
435
440
  pair_id: string;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Diagnostic parsing + four-bucket classification for the v2 check phase.
2
+ * Diagnostic parsing + bucket classification for the v2 check phase.
3
3
  *
4
4
  * The judge is the vendored `tsc` CLI with `--pretty false`, run from the
5
5
  * workspace root so file locations print workspace-relative (no temp path in
@@ -16,6 +16,8 @@
16
16
  * raw-text read marked on a side -> unverifiable (text, even agreeing)
17
17
  * assignment error, sent union taken in part by a weak expected type
18
18
  * -> unverifiable (carrick#1995)
19
+ * assignment error on the published type that the sent side's unwidened
20
+ * reading does not repeat -> producer_wider (carrick#1538)
19
21
  * assignment-class error -> incompatible
20
22
  * no diagnostics -> compatible [lowest precedence]
21
23
  *
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Diagnostic parsing + four-bucket classification for the v2 check phase.
2
+ * Diagnostic parsing + bucket classification for the v2 check phase.
3
3
  *
4
4
  * The judge is the vendored `tsc` CLI with `--pretty false`, run from the
5
5
  * workspace root so file locations print workspace-relative (no temp path in
@@ -16,6 +16,8 @@
16
16
  * raw-text read marked on a side -> unverifiable (text, even agreeing)
17
17
  * assignment error, sent union taken in part by a weak expected type
18
18
  * -> unverifiable (carrick#1995)
19
+ * assignment error on the published type that the sent side's unwidened
20
+ * reading does not repeat -> producer_wider (carrick#1538)
19
21
  * assignment-class error -> incompatible
20
22
  * no diagnostics -> compatible [lowest precedence]
21
23
  *
@@ -25,7 +27,7 @@
25
27
  *
26
28
  * Seam: node builtins + this bundle only.
27
29
  */
28
- import { decisiveAssignmentLine } from './check-probe.js';
30
+ import { decisiveAssignmentLine, unwidenedAliasOf, } from './check-probe.js';
29
31
  import { scrubDiagnostic, scrubPaths } from './check-scrub.js';
30
32
  import { describeFieldReport, fieldReportNotes, } from './check-fields.js';
31
33
  const PRIMARY_RE = /^(?<file>(?:[a-zA-Z]:)?[^(]*?)\((?<line>\d+),(?<col>\d+)\): error TS(?<code>\d+): (?<msg>.*)$/;
@@ -72,7 +74,11 @@ function endpointAliasFor(side, plan) {
72
74
  /** Classify one pair into exactly one bucket, honouring the precedence order. */
73
75
  export function classifyPair(input) {
74
76
  const { plan, probeDiags, poisonReason, scrubCtx } = input;
75
- const codes = [...new Set(probeDiags.map((d) => d.code))].sort((a, b) => a - b);
77
+ // The lines of the unwidened reading (carrick#1538) answer one question, in
78
+ // 5b below. Their diagnostics are no part of what the published pair raised.
79
+ const codes = [
80
+ ...new Set(probeDiags.filter((d) => !onUnwidenedLines(plan, d)).map((d) => d.code)),
81
+ ].sort((a, b) => a - b);
76
82
  // `notes` sits on `base` so EVERY bucket carries it, including the
77
83
  // compatible one (carrick#1341). The two statements it holds are true of a
78
84
  // pair that agreed — an optionality gap assigns, and the wire allowance is
@@ -240,6 +246,14 @@ export function classifyPair(input) {
240
246
  ...notAFact(`the ${sent} type is a union of bodies and the ${expected} type, every field optional, accepts only some of them`),
241
247
  };
242
248
  }
249
+ // 5b. Or the mismatch is the published type's alone (carrick#1538): a
250
+ // literal widened where the handler's return was inferred. The handler's
251
+ // return read with no literal widened fits what the other side expects,
252
+ // so what travels agrees and only the published type is wider. Its own
253
+ // bucket, which carries no diagnostic: a reason is read as a mismatch.
254
+ if (assignDiag && unwidenedReadingFits(input)) {
255
+ return { ...base, bucket: 'producer_wider', ...factness(input) };
256
+ }
243
257
  if (assignDiag) {
244
258
  const text = scrubDiagnostic(assignDiag.message, scrubCtx, plan.sentEndpoint.alias, plan.expectedEndpoint.alias);
245
259
  return {
@@ -297,6 +311,36 @@ export function classifyPair(input) {
297
311
  // 6. No diagnostics -> compatible.
298
312
  return { ...base, bucket: 'compatible', ...factness(input) };
299
313
  }
314
+ function onUnwidenedLines(plan, diagnostic) {
315
+ return plan.unwidenedFromLine !== undefined && diagnostic.line >= plan.unwidenedFromLine;
316
+ }
317
+ /**
318
+ * Whether the sent side's unwidened reading clears a mismatch on its published
319
+ * type (carrick#1538). Every condition must hold, and each absence leaves the
320
+ * published verdict as it is:
321
+ *
322
+ * - the probe carries the reading (its stub ships the unwidened alias);
323
+ * - the compiler raised nothing on its lines: not on the import (the export is
324
+ * there), not on its top-type gates, not on its assignment;
325
+ * - its own declarations typecheck (the alias is not poisoned);
326
+ * - the member-level walk ran over it and found no `any`/`unknown`, which
327
+ * would assign to whatever the other side declares;
328
+ * - the same walk found none on the two published sides either. A mismatch
329
+ * that is not a fact stays the mismatch it was: the reading would be
330
+ * clearing it against a member that accepts anything.
331
+ */
332
+ function unwidenedReadingFits(input) {
333
+ const { plan, probeDiags, poisonReason } = input;
334
+ if (plan.unwidenedFromLine === undefined)
335
+ return false;
336
+ if (probeDiags.some((d) => onUnwidenedLines(plan, d)))
337
+ return false;
338
+ const sent = plan.sentEndpoint;
339
+ if (poisonReason(sent.service_name, unwidenedAliasOf(sent.alias)))
340
+ return false;
341
+ const walked = input.deepFindings?.sentUnwidened;
342
+ return walked !== undefined && walked.length === 0 && factness(input).resolved;
343
+ }
300
344
  /**
301
345
  * The field-level sentence appended to a mismatch diagnostic
302
346
  * (carrick-tools/carrick-cloud#1118), or `''` when the walk found nothing to
@@ -14,14 +14,24 @@
14
14
  * It is used for exactly one thing: setting `CheckVerdict.resolved`. No bucket
15
15
  * changes because of it, so a scan's verdicts are identical with and without
16
16
  * it — what changes is whether a reader is told the verdict is a fact.
17
+ *
18
+ * One more reading rides the same walk (carrick#1538): the sent side's
19
+ * unwidened alias, where a probe carries it. A member-level `any` there would
20
+ * let that reading assign to anything, so the classifier lets it clear a
21
+ * mismatch only when this walk ran over it and found nothing.
17
22
  */
18
23
  import ts from 'typescript';
19
24
  import type { TypeProvenance } from './api.js';
20
- import type { ProbePlan } from './check-probe.js';
25
+ import { type ProbePlan } from './check-probe.js';
21
26
  /** Deep findings for one pair, per probe side. */
22
27
  export interface PairDeepFindings {
23
28
  sent: TypeProvenance[];
24
29
  expected: TypeProvenance[];
30
+ /**
31
+ * Findings on the sent side's unwidened reading (carrick#1538). Absent when
32
+ * the probe carries none, or when its export could not be resolved.
33
+ */
34
+ sentUnwidened?: TypeProvenance[];
25
35
  }
26
36
  /** The one compiler program over the assembled probes, shared by every
27
37
  * post-judge walk (fact-ness, and the field-level report the mismatch text
@@ -14,12 +14,18 @@
14
14
  * It is used for exactly one thing: setting `CheckVerdict.resolved`. No bucket
15
15
  * changes because of it, so a scan's verdicts are identical with and without
16
16
  * it — what changes is whether a reader is told the verdict is a fact.
17
+ *
18
+ * One more reading rides the same walk (carrick#1538): the sent side's
19
+ * unwidened alias, where a probe carries it. A member-level `any` there would
20
+ * let that reading assign to anything, so the classifier lets it clear a
21
+ * mismatch only when this walk ran over it and found nothing.
17
22
  */
18
23
  import ts from 'typescript';
19
24
  import * as fs from 'node:fs';
20
25
  import * as path from 'node:path';
21
26
  import { sidecarCompilerOptions } from './compiler-options.js';
22
27
  import { findDisqualifyingTopTypes, provenanceOf } from './deep-walk.js';
28
+ import { UNWIDENED_LOCAL_NAME } from './check-probe.js';
23
29
  export function openProbeProgram(probesDir, plans) {
24
30
  if (plans.length === 0)
25
31
  return undefined;
@@ -63,7 +69,14 @@ export function probeDeepFindings(opened, plans) {
63
69
  const expected = walkImportedAlias(file, 'Expected', program, checker);
64
70
  if (sent === undefined || expected === undefined)
65
71
  continue;
66
- results.set(plan.pairId, { sent, expected });
72
+ const sentUnwidened = plan.unwidenedFromLine === undefined
73
+ ? undefined
74
+ : walkImportedAlias(file, UNWIDENED_LOCAL_NAME, program, checker);
75
+ results.set(plan.pairId, {
76
+ sent,
77
+ expected,
78
+ ...(sentUnwidened === undefined ? {} : { sentUnwidened }),
79
+ });
67
80
  }
68
81
  return results;
69
82
  }
@@ -84,7 +84,28 @@ export interface ProbePlan {
84
84
  * the form that actually travels.
85
85
  */
86
86
  wireAssignmentLine?: number;
87
+ /**
88
+ * 1-based line where the second reading of the sent side starts
89
+ * (carrick#1538): the import of the producer's UNWIDENED alias, its
90
+ * top-type gates and one more assignment, all after every other line, so no
91
+ * line above it moves. Present only when the caller named that alias.
92
+ *
93
+ * Nothing from this line on is an import line, a gate line or an assignment
94
+ * line of the plan. The classifier reads these lines for one question only:
95
+ * whether a mismatch on the published type holds for the type the handler
96
+ * sends with no literal widened.
97
+ */
98
+ unwidenedFromLine?: number;
87
99
  }
100
+ /**
101
+ * The surface export that carries a response alias's unwidened reading
102
+ * (carrick#1516). The scanner names it when it asks the capture for one
103
+ * (`unwidened_alias` in `src/engine/type_compat_v2.rs`); this is the same
104
+ * name, read back.
105
+ */
106
+ export declare function unwidenedAliasOf(alias: string): string;
107
+ /** The local name the probe imports the unwidened alias under. */
108
+ export declare const UNWIDENED_LOCAL_NAME = "SentUnwidened";
88
109
  /** The assignment line whose diagnostic decides the bucket. */
89
110
  /**
90
111
  * The JSON wire transform as TypeScript declarations: `JsonWire<T>` is the
@@ -114,5 +135,12 @@ export declare function decisiveAssignmentLine(plan: ProbePlan): number;
114
135
  * Build one probe, recording the exact line of every gate and the assignment so
115
136
  * the classifier never depends on hard-coded offsets. `packageOf` maps a
116
137
  * service name to its workspace package specifier (e.g. `@carrick/orders`).
138
+ *
139
+ * `unwidenedAliasFor` answers, for a producer that is the sending side, the
140
+ * surface export that carries its unwidened reading (carrick#1538), when its
141
+ * stub ships one. The probe of an `http` response then ends with a second
142
+ * assignment of that reading, in the wire form. Without an answer the
143
+ * probe is what it always was, byte for byte. A consumer that sends (a request
144
+ * body) is never asked: the reading is of a handler's return.
117
145
  */
118
- export declare function buildProbe(spec: CheckPairSpec, packageOf: (serviceName: string) => string): ProbePlan;
146
+ export declare function buildProbe(spec: CheckPairSpec, packageOf: (serviceName: string) => string, unwidenedAliasFor?: (sentProducer: CheckPairEndpoint) => string | undefined): ProbePlan;
@@ -74,6 +74,17 @@ export function pairId(spec) {
74
74
  ].join('|');
75
75
  return fnv1a(key);
76
76
  }
77
+ /**
78
+ * The surface export that carries a response alias's unwidened reading
79
+ * (carrick#1516). The scanner names it when it asks the capture for one
80
+ * (`unwidened_alias` in `src/engine/type_compat_v2.rs`); this is the same
81
+ * name, read back.
82
+ */
83
+ export function unwidenedAliasOf(alias) {
84
+ return `${alias}_Unwidened`;
85
+ }
86
+ /** The local name the probe imports the unwidened alias under. */
87
+ export const UNWIDENED_LOCAL_NAME = 'SentUnwidened';
77
88
  /** The assignment line whose diagnostic decides the bucket. */
78
89
  /**
79
90
  * The JSON wire transform as TypeScript declarations: `JsonWire<T>` is the
@@ -114,8 +125,15 @@ export function decisiveAssignmentLine(plan) {
114
125
  * Build one probe, recording the exact line of every gate and the assignment so
115
126
  * the classifier never depends on hard-coded offsets. `packageOf` maps a
116
127
  * service name to its workspace package specifier (e.g. `@carrick/orders`).
128
+ *
129
+ * `unwidenedAliasFor` answers, for a producer that is the sending side, the
130
+ * surface export that carries its unwidened reading (carrick#1538), when its
131
+ * stub ships one. The probe of an `http` response then ends with a second
132
+ * assignment of that reading, in the wire form. Without an answer the
133
+ * probe is what it always was, byte for byte. A consumer that sends (a request
134
+ * body) is never asked: the reading is of a handler's return.
117
135
  */
118
- export function buildProbe(spec, packageOf) {
136
+ export function buildProbe(spec, packageOf, unwidenedAliasFor) {
119
137
  const id = pairId(spec);
120
138
  const direction = directionFor(spec.protocol, spec.type_kind);
121
139
  const sentEndpoint = direction.sent === 'producer' ? spec.producer : spec.consumer;
@@ -270,6 +288,36 @@ export function buildProbe(spec, packageOf) {
270
288
  push(`declare const sentWire: WireSent;`);
271
289
  wireAssignmentLine = push(`const expectedWire: Expected = sentWire;`);
272
290
  }
291
+ // The unwidened reading (carrick#1538). A handler that returns
292
+ // `cond ? 'a' : 'b'` publishes `string`, because a literal widens where it
293
+ // is inferred, and a consumer that declares `'a' | 'b'` then mismatches a
294
+ // response it reads correctly. The capture keeps the reading with no literal
295
+ // widened beside the published alias, and it is assigned here a second time.
296
+ //
297
+ // Everything sits after the lines above, the import included, and none of it
298
+ // is recorded as an import, gate or assignment line: a missing export, a top
299
+ // type or a mismatch here says only that the second reading did not clear
300
+ // the pair, and the verdict on the published type stands.
301
+ //
302
+ // Only an `http` response gets one. It is the one pair a producer sends on
303
+ // whose decisive assignment a second reading can mirror, here in the wire
304
+ // form. A GraphQL pair compares an unwrapped comparand (above), which a
305
+ // plain assignment of the reading would not mirror, and on every other
306
+ // protocol the consumer is the sender (the direction table).
307
+ let unwidenedFromLine;
308
+ const sentUnwidenedAlias = direction.sent === 'producer' && spec.protocol === 'http'
309
+ ? unwidenedAliasFor?.(sentEndpoint)
310
+ : undefined;
311
+ if (sentUnwidenedAlias !== undefined) {
312
+ const local = UNWIDENED_LOCAL_NAME;
313
+ unwidenedFromLine = push(`import type { ${sentUnwidenedAlias} as ${local} } from '${sentPkg}';`);
314
+ push(`type _G_unwidened_any = Assert<Not<IsAny<${local}>>>;`);
315
+ push(`type _G_unwidened_unknown = Assert<Not<IsUnknown<${local}>>>;`);
316
+ push(`type _G_unwidened_never = Assert<Not<IsNever<${local}>>>;`);
317
+ push(`type WireSentUnwidened = [${local}] extends [Expected] ? ${local} : (JsonWireSame<JsonWire<${local}>, ${local}> extends true ? ${local} : JsonWire<${local}>);`);
318
+ push(`declare const sentWireUnwidened: WireSentUnwidened;`);
319
+ push(`const expectedWireUnwidened: Expected = sentWireUnwidened;`);
320
+ }
273
321
  return {
274
322
  pairId: id,
275
323
  spec,
@@ -282,5 +330,6 @@ export function buildProbe(spec, packageOf) {
282
330
  gateLines,
283
331
  assignmentLine,
284
332
  wireAssignmentLine,
333
+ ...(unwidenedFromLine === undefined ? {} : { unwidenedFromLine }),
285
334
  };
286
335
  }
@@ -19,7 +19,7 @@ import * as fs from 'node:fs';
19
19
  import * as path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import ts from 'typescript';
22
- import { buildProbe } from './check-probe.js';
22
+ import { buildProbe, unwidenedAliasOf } from './check-probe.js';
23
23
  import { classifyPair, parseTscOutput, } from './check-classify.js';
24
24
  import { scrubPaths } from './check-scrub.js';
25
25
  import { assembleWorkspace, writeProbes, } from './check-workspace.js';
@@ -190,11 +190,12 @@ export async function runCheck(opts, onProgress) {
190
190
  packageLabelOf: ws.packageLabelOf,
191
191
  };
192
192
  // Generate probes. A pair naming a service with no stub is unverifiable.
193
+ const aliasRecords = readStubAliasRecords(opts.stubs);
193
194
  const plans = [];
194
195
  const unresolved = [];
195
196
  for (const spec of opts.pairs) {
196
197
  try {
197
- plans.push(buildProbe(spec, ws.packageOf));
198
+ plans.push(buildProbe(spec, ws.packageOf, unwidenedAliasIn(aliasRecords)));
198
199
  }
199
200
  catch {
200
201
  unresolved.push({
@@ -215,7 +216,6 @@ export async function runCheck(opts, onProgress) {
215
216
  const spec = opts.pairs.find((p) => p.pair_key === v.pair_key);
216
217
  v.pair_id = fnvOfSpec(spec);
217
218
  }
218
- const aliasRecords = readStubAliasRecords(opts.stubs);
219
219
  const { preGated, probing } = preGate(plans, aliasRecords);
220
220
  writeProbes(ws, probing);
221
221
  const errors = [];
@@ -386,6 +386,23 @@ function readStubAliasRecords(stubs) {
386
386
  }
387
387
  return byService;
388
388
  }
389
+ /**
390
+ * The unwidened reading a producer's stub ships beside one of its aliases
391
+ * (carrick#1538), as `buildProbe` asks for it. Read from the same manifest the
392
+ * pre-gate reads. A reading the capture already found a member-level
393
+ * `any`/`unknown` in is not offered: it would assign to whatever the other
394
+ * side declares.
395
+ */
396
+ function unwidenedAliasIn(aliasRecords) {
397
+ return (sentProducer) => {
398
+ const record = aliasRecords
399
+ .get(sentProducer.service_name)
400
+ ?.get(unwidenedAliasOf(sentProducer.alias));
401
+ if (!record || record.any_provenance?.length)
402
+ return undefined;
403
+ return record.alias;
404
+ };
405
+ }
389
406
  /**
390
407
  * Capture-time deep-decay pre-gate (adversarial-review finding 1): a
391
408
  * member-level `any`/`unknown` recorded by the capture self-check with no
@@ -60,4 +60,4 @@ export declare function surfaceEntryFileName(): string;
60
60
  /** Same normalization intent as bundle_file_stems on the Rust side. */
61
61
  export declare function sanitizeServiceName(name: string): string;
62
62
  export declare function captureStub(opts: CaptureStubOptions): CaptureStubResult;
63
- export { classifyStatusCodes, constituents, httpStatus, statesNoBody, statusKeyCode, statusTableBody, statusTableOf, successEntries, } from './status-table.js';
63
+ export { classifyStatusCodes, constituents, httpStatus, statesNoBody, statesNoShape, statusKeyCode, statusTableBody, statusTableOf, successEntries, } from './status-table.js';
@@ -266,7 +266,11 @@ export function captureStub(opts) {
266
266
  // ---- Phase A: analysis program over placeholder entry + anchor sources ----
267
267
  let resolved;
268
268
  const progress = opts.onProgress ?? (() => { });
269
- const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath, guard, progress, bound };
269
+ // The files of a project that declare a module or global augmentation are
270
+ // roots of both programs below: the one its anchors are read in and the one
271
+ // that emits the surface (carrick#2317). Read once per project.
272
+ const augmentations = augmentationReader(bound);
273
+ const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath, guard, progress, bound, augmentations };
270
274
  try {
271
275
  resolved = ownerGroups && emitProject
272
276
  ? resolveAnchorsByOwner(opts, ownerGroups, emitProject, analysisCtx, errors)
@@ -275,8 +279,8 @@ export function captureStub(opts) {
275
279
  catch (err) {
276
280
  return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
277
281
  }
278
- // ---- Augmentation detection over the tsconfig's full file list ----
279
- const augmentationSources = [...new Set([...findAugmentationFiles(parsed.fileNames.filter((f) => !f.includes(`${path.sep}node_modules${path.sep}`))), ...(deno?.globals ?? [])])];
282
+ // ---- Augmentation files of the project that emits the surface ----
283
+ const augmentationSources = [...new Set([...augmentations(parsed), ...(deno?.globals ?? [])])];
280
284
  // ---- Phase B: declaration emit of the final entry ----
281
285
  const entryLines = ['// Generated by Carrick capture v2. Deleted after emit.'];
282
286
  for (const anchor of resolved) {
@@ -706,6 +710,27 @@ function programHost(options, deno, bound) {
706
710
  return deno?.host(options);
707
711
  return boundedCompilerHost(deno ? deno.host(options) : ts.createCompilerHost(options), bound);
708
712
  }
713
+ /**
714
+ * The files a project's config lists that declare a module or global
715
+ * augmentation, for one capture: each project's list is read from disk once,
716
+ * however many programs are built over it.
717
+ *
718
+ * A project's own compile holds these files whether or not anything imports
719
+ * them, so a program that stands in for that compile must hold them too. A
720
+ * file under `node_modules` is the package's business, and a file outside the
721
+ * read bound is not opened, not even to look for the declaration.
722
+ */
723
+ function augmentationReader(bound) {
724
+ const byProject = new Map();
725
+ return (parsed) => {
726
+ let files = byProject.get(parsed);
727
+ if (!files) {
728
+ files = findAugmentationFiles(parsed.fileNames.filter((f) => !f.includes(`${path.sep}node_modules${path.sep}`) && (!bound || bound.allows(f))));
729
+ byProject.set(parsed, files);
730
+ }
731
+ return files;
732
+ };
733
+ }
709
734
  /** Phase A: build the placeholder entry, then resolve every anchor. */
710
735
  function resolveAnchors(opts, parsed, ctx, deno) {
711
736
  const placeholderLines = ['// Carrick capture v2 analysis placeholder.'];
@@ -723,7 +748,12 @@ function resolveAnchors(opts, parsed, ctx, deno) {
723
748
  ...parsed.options,
724
749
  noEmit: true,
725
750
  });
726
- const program = ts.createProgram([ctx.entryPath, ...anchorSources, ...(deno?.globals ?? [])], options, programHost(options, deno, ctx.bound));
751
+ // The project's augmentation files are roots: its own compile reads a
752
+ // member one of them declares wherever the member is read, imported or not.
753
+ const rootNames = [
754
+ ...new Set([ctx.entryPath, ...anchorSources, ...ctx.augmentations(parsed), ...(deno?.globals ?? [])]),
755
+ ];
756
+ const program = ts.createProgram(rootNames, options, programHost(options, deno, ctx.bound));
727
757
  // The checker binds every file of the program when it is first asked for,
728
758
  // which the first anchor would otherwise do: asked for here, so that the
729
759
  // report below says the program is whole before any anchor is read.
@@ -780,4 +810,4 @@ function resolveAnchors(opts, parsed, ctx, deno) {
780
810
  }
781
811
  // carrick#1841: the response-table reading the inferrer and the v1 bundle
782
812
  // share with the capture's symbol anchor.
783
- export { classifyStatusCodes, constituents, httpStatus, statesNoBody, statusKeyCode, statusTableBody, statusTableOf, successEntries, } from './status-table.js';
813
+ export { classifyStatusCodes, constituents, httpStatus, statesNoBody, statesNoShape, statusKeyCode, statusTableBody, statusTableOf, successEntries, } from './status-table.js';
@@ -44,6 +44,23 @@ export declare function successEntries(entries: StatusTableEntry[]): StatusTable
44
44
  * made only of them. A `204: void` row is such a body.
45
45
  */
46
46
  export declare function statesNoBody(type: ts.Type): boolean;
47
+ /**
48
+ * Whether a payload type states no shape (carrick#2116): at its root, or as
49
+ * a member of its root union, it is
50
+ *
51
+ * - the checker's own empty object type, which is what narrowing `unknown`
52
+ * yields (`if (cached != null) return cached`). It has no property, no
53
+ * signature, no index signature and, unlike every `{}` the source writes,
54
+ * no symbol: an empty object literal carries `__object`, a declared `{}`
55
+ * carries `__type`, an empty interface carries its own; or
56
+ * - the `object` keyword type.
57
+ *
58
+ * Printed, the first reads `{}` like an empty object a handler really sends,
59
+ * so only the source program can tell them apart. A reader answers `unknown`
60
+ * for such a type, as it does for one that is `unknown` at its root. A
61
+ * member's own type is not looked at: `meta: {}` stays a member.
62
+ */
63
+ export declare function statesNoShape(checker: ts.TypeChecker, type: ts.Type): boolean;
47
64
  /**
48
65
  * What a response table's success rows carry, read for a consumer:
49
66
  * - `body`: the 2xx rows' value types, at least one of which states a body;
@@ -81,6 +81,32 @@ export function statesNoBody(type) {
81
81
  const members = type.isUnion() ? type.types : [type];
82
82
  return members.every((member) => (member.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never)) !== 0);
83
83
  }
84
+ /**
85
+ * Whether a payload type states no shape (carrick#2116): at its root, or as
86
+ * a member of its root union, it is
87
+ *
88
+ * - the checker's own empty object type, which is what narrowing `unknown`
89
+ * yields (`if (cached != null) return cached`). It has no property, no
90
+ * signature, no index signature and, unlike every `{}` the source writes,
91
+ * no symbol: an empty object literal carries `__object`, a declared `{}`
92
+ * carries `__type`, an empty interface carries its own; or
93
+ * - the `object` keyword type.
94
+ *
95
+ * Printed, the first reads `{}` like an empty object a handler really sends,
96
+ * so only the source program can tell them apart. A reader answers `unknown`
97
+ * for such a type, as it does for one that is `unknown` at its root. A
98
+ * member's own type is not looked at: `meta: {}` stays a member.
99
+ */
100
+ export function statesNoShape(checker, type) {
101
+ const members = type.isUnion() ? type.types : [type];
102
+ return members.some((member) => (member.flags & ts.TypeFlags.NonPrimitive) !== 0 ||
103
+ ((member.flags & ts.TypeFlags.Object) !== 0 &&
104
+ member.symbol === undefined &&
105
+ checker.getPropertiesOfType(member).length === 0 &&
106
+ checker.getSignaturesOfType(member, ts.SignatureKind.Call).length === 0 &&
107
+ checker.getSignaturesOfType(member, ts.SignatureKind.Construct).length === 0 &&
108
+ checker.getIndexInfosOfType(member).length === 0));
109
+ }
84
110
  /**
85
111
  * What a response table's success rows carry, read for a consumer:
86
112
  * - `body`: the 2xx rows' value types, at least one of which states a body;
@@ -322,7 +322,7 @@ function handleInfer(request) {
322
322
  const results = [...byProject(request.requests, (item) => item.file_path)].map(([key, items]) => projectComponents(key).typeInferrer.infer(items, request.extraction_config, () => {
323
323
  done += 1;
324
324
  report();
325
- }));
325
+ }, request.model_symbols));
326
326
  const result = results.length === 1
327
327
  ? results[0]
328
328
  : (() => {