carrick 0.3.72 → 0.3.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +6 -6
  2. package/sidecar/dist/src/capture/anchors.d.ts +15 -0
  3. package/sidecar/dist/src/capture/anchors.js +70 -5
  4. package/sidecar/dist/src/capture/api.d.ts +39 -4
  5. package/sidecar/dist/src/capture/check-classify.d.ts +2 -0
  6. package/sidecar/dist/src/capture/check-classify.js +28 -0
  7. package/sidecar/dist/src/capture/check-deep.js +1 -1
  8. package/sidecar/dist/src/capture/check-probe.d.ts +1 -1
  9. package/sidecar/dist/src/capture/check-probe.js +16 -0
  10. package/sidecar/dist/src/capture/deep-walk.d.ts +33 -6
  11. package/sidecar/dist/src/capture/deep-walk.js +81 -28
  12. package/sidecar/dist/src/capture/index.js +16 -6
  13. package/sidecar/dist/src/capture/installed-package.d.ts +58 -0
  14. package/sidecar/dist/src/capture/installed-package.js +311 -0
  15. package/sidecar/dist/src/capture/lockfile.d.ts +8 -0
  16. package/sidecar/dist/src/capture/lockfile.js +1 -1
  17. package/sidecar/dist/src/capture/node-builder.d.ts +27 -0
  18. package/sidecar/dist/src/capture/node-builder.js +107 -1
  19. package/sidecar/dist/src/capture/paths-rewrite.d.ts +23 -6
  20. package/sidecar/dist/src/capture/paths-rewrite.js +43 -11
  21. package/sidecar/dist/src/capture/self-check.js +12 -3
  22. package/sidecar/dist/src/capture/unresolved.d.ts +28 -0
  23. package/sidecar/dist/src/capture/unresolved.js +107 -0
  24. package/sidecar/dist/src/type-inferrer.d.ts +108 -11
  25. package/sidecar/dist/src/type-inferrer.js +513 -90
  26. package/sidecar/dist/src/type-structural-expander.d.ts +23 -2
  27. package/sidecar/dist/src/type-structural-expander.js +55 -17
  28. package/sidecar/dist/src/type-text-canonicalizer.d.ts +14 -0
  29. package/sidecar/dist/src/type-text-canonicalizer.js +23 -2
  30. package/sidecar/dist/src/validators.d.ts +10 -0
  31. package/sidecar/dist/src/validators.js +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carrick",
3
- "version": "0.3.72",
3
+ "version": "0.3.73",
4
4
  "description": "The API contract index for a TypeScript workspace: what the other services do with the routes and calls in the file you are editing, in your editor and in your agent's context",
5
5
  "keywords": [
6
6
  "typescript",
@@ -57,11 +57,11 @@
57
57
  "zod": "^3.23.0"
58
58
  },
59
59
  "optionalDependencies": {
60
- "@carrick-tools/cli-darwin-arm64": "0.3.72",
61
- "@carrick-tools/cli-darwin-x64": "0.3.72",
62
- "@carrick-tools/cli-linux-arm64": "0.3.72",
63
- "@carrick-tools/cli-linux-x64": "0.3.72",
64
- "@carrick-tools/cli-win32-x64": "0.3.72"
60
+ "@carrick-tools/cli-darwin-arm64": "0.3.73",
61
+ "@carrick-tools/cli-darwin-x64": "0.3.73",
62
+ "@carrick-tools/cli-linux-arm64": "0.3.73",
63
+ "@carrick-tools/cli-linux-x64": "0.3.73",
64
+ "@carrick-tools/cli-win32-x64": "0.3.73"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@types/node": "^24.13.3",
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import ts from 'typescript';
8
8
  import type { CaptureAnchorRequest, InferAnchorRequest } from './api.js';
9
+ import type { UnresolvedAtAnchor } from './deep-walk.js';
9
10
  export interface ResolvedAnchor {
10
11
  request: CaptureAnchorRequest;
11
12
  /** RHS of `export type <alias> = ...;` in the surface entry. */
@@ -34,6 +35,20 @@ export interface ResolvedAnchor {
34
35
  * backfillable; the reason rides `self_check_detail` instead.
35
36
  */
36
37
  abstainReason?: string;
38
+ /**
39
+ * carrick#1165: identifiers the alias text names (a literal anchor's text
40
+ * or a node-builder print) that nothing in the producer's program declares.
41
+ * Recorded on the capture record as `undeclared_names`.
42
+ */
43
+ undeclaredNames?: string[];
44
+ /**
45
+ * carrick#1164: member paths the SOURCE program could not resolve, and the
46
+ * unresolved imports the anchor's file reaches. Printed into the surface
47
+ * those members read `any`, like an author's `any`; the self-check uses this
48
+ * to label them `unresolved_import` instead of `declared`. Absent when the
49
+ * anchor's type holds no unresolved placeholder.
50
+ */
51
+ unresolved?: UnresolvedAtAnchor;
37
52
  }
38
53
  /** Repo-root-relative source file -> extensionless specifier from entryDir. */
39
54
  export declare function entryRelativeSpecifier(entryDir: string, repoRoot: string, sourceFile: string): string;
@@ -6,8 +6,9 @@
6
6
  */
7
7
  import ts from 'typescript';
8
8
  import * as path from 'node:path';
9
- import { printTypeForDestination } from './node-builder.js';
9
+ import { printTypeForDestination, undeclaredNamesIn } from './node-builder.js';
10
10
  import { typeIsOrContainsMachinery } from './machinery.js';
11
+ import { unresolvedAtAnchor } from './unresolved.js';
11
12
  /** Repo-root-relative source file -> extensionless specifier from entryDir. */
12
13
  export function entryRelativeSpecifier(entryDir, repoRoot, sourceFile) {
13
14
  const target = path
@@ -48,6 +49,14 @@ export function resolveAnchor(program, request, args) {
48
49
  const siblingSpec = bareIdentifier
49
50
  ? args.siblingSymbolSpecs?.get(text)
50
51
  : undefined;
52
+ // carrick#1165: literal text is printed elsewhere (the v1 walk) and can
53
+ // name a type by a bare identifier that nothing in the program declares
54
+ // (a generated model that was never generated). The stub then self-checks
55
+ // such a name as an error placeholder, which no walk flags. A name a
56
+ // sibling symbol anchor imports is resolved by that import.
57
+ const undeclaredNames = siblingSpec || !args.placeholder
58
+ ? []
59
+ : undeclaredNamesInText(text, program, args.placeholder);
51
60
  return {
52
61
  request,
53
62
  aliasText: siblingSpec ? `import('${siblingSpec}').${text}` : text,
@@ -57,6 +66,7 @@ export function resolveAnchor(program, request, args) {
57
66
  // them at this tier so the legacy dependence stays measurable and
58
67
  // ratchetable. Demotions are distinguished by failureReason.
59
68
  serialization: 'structural_fallback',
69
+ ...(undeclaredNames.length > 0 ? { undeclaredNames } : {}),
60
70
  };
61
71
  }
62
72
  const sourceAbs = path.join(args.repoRoot, request.source_file);
@@ -100,10 +110,14 @@ export function resolveAnchor(program, request, args) {
100
110
  `and no sibling type alias derives from it (e.g. \`typeof ${request.symbol_name}\`); ` +
101
111
  'demoted so the surface line stays valid');
102
112
  }
113
+ const arrayDepth = Math.max(0, request.array_depth ?? 0);
114
+ const declared = checker.getDeclaredTypeOfSymbol(resolvedExport);
115
+ const unresolved = unresolvedAtAnchor(program, sourceFile, declared, resolvedExport.declarations?.[0] ?? sourceFile, '<0>'.repeat(arrayDepth));
103
116
  return {
104
117
  request,
105
118
  aliasText: `import('${spec}').${request.symbol_name}${arraySuffix}`,
106
119
  serialization: 'emitted',
120
+ ...(unresolved ? { unresolved } : {}),
107
121
  };
108
122
  }
109
123
  if (request.kind === 'handler_return') {
@@ -126,10 +140,13 @@ export function resolveAnchor(program, request, args) {
126
140
  // Type parameters erase to their constraint/unknown under ReturnType<>.
127
141
  return demote(`handler '${request.symbol_name}' is generic`);
128
142
  }
143
+ const returned = callSignatures[0].getReturnType();
144
+ const unresolved = unresolvedAtAnchor(program, sourceFile, checker.getAwaitedType(returned) ?? returned, declaration ?? sourceFile);
129
145
  return {
130
146
  request,
131
147
  aliasText: `Awaited<ReturnType<typeof import('${spec}').${request.symbol_name}>>`,
132
148
  serialization: 'emitted',
149
+ ...(unresolved ? { unresolved } : {}),
133
150
  };
134
151
  }
135
152
  // kind === 'infer'
@@ -151,6 +168,10 @@ export function resolveAnchor(program, request, args) {
151
168
  if (!located) {
152
169
  return demote(locatorFailureReason(request));
153
170
  }
171
+ // carrick#1162: a serialised body is the JSON of its argument. The call's own
172
+ // `string` result is never the payload's contract, and publishing it reads
173
+ // incompatible against every object-typed counterparty.
174
+ located = serialisedArgument(located);
154
175
  // #439 part 1: a producer anchor whose locator landed inside a fluent
155
176
  // builder chain's config-descriptor argument (an all-literal metadata
156
177
  // object) must never capture that descriptor as the request type. Re-aim at
@@ -255,13 +276,24 @@ function finishInferAnchor(program, sourceFile, request, located, placeholder, r
255
276
  if (!printed.text) {
256
277
  return demote(printed.failure ?? 'node builder print failed');
257
278
  }
279
+ const unresolved = unresolvedAtAnchor(program, sourceFile, type, located);
258
280
  return {
259
281
  request,
260
282
  aliasText: printed.text,
261
283
  serialization: 'node_builder',
262
284
  ...(reaimNote ? { reaimNote } : {}),
285
+ ...(printed.undeclaredNames ? { undeclaredNames: printed.undeclaredNames } : {}),
286
+ ...(unresolved ? { unresolved } : {}),
263
287
  };
264
288
  }
289
+ /** `undeclaredNamesIn` over type text rather than a built node. */
290
+ function undeclaredNamesInText(text, program, destination) {
291
+ const parsed = ts.createSourceFile('literal-anchor.ts', `type __LiteralAnchor = ${text};`, ts.ScriptTarget.Latest, true);
292
+ const statement = parsed.statements[0];
293
+ if (!statement || !ts.isTypeAliasDeclaration(statement))
294
+ return [];
295
+ return undeclaredNamesIn(statement.type, program, destination);
296
+ }
265
297
  /**
266
298
  * True when the anchor carries a LINE and nothing else — no payload span, no
267
299
  * expression text, no parameter name. Such an anchor states where to look, not
@@ -689,6 +721,26 @@ function paramLocatorHints(request) {
689
721
  ? `line ${request.line_number}`
690
722
  : 'no line hint';
691
723
  }
724
+ /**
725
+ * The argument of a `JSON.stringify(value)` call (through parentheses), or the
726
+ * node unchanged. The mirror of the v1 inferrer's `unwrapJsonStringifyArg`:
727
+ * the global serialiser is identified by the standard `JSON` object, the one
728
+ * name every runtime shares.
729
+ */
730
+ function serialisedArgument(node) {
731
+ let current = node;
732
+ while (ts.isParenthesizedExpression(current))
733
+ current = current.expression;
734
+ if (ts.isCallExpression(current) &&
735
+ current.arguments.length > 0 &&
736
+ ts.isPropertyAccessExpression(current.expression) &&
737
+ ts.isIdentifier(current.expression.expression) &&
738
+ current.expression.expression.text === 'JSON' &&
739
+ current.expression.name.text === 'stringify') {
740
+ return current.arguments[0];
741
+ }
742
+ return node;
743
+ }
692
744
  function locatorFailureReason(request) {
693
745
  const hints = [];
694
746
  if (request.span_start !== undefined)
@@ -741,17 +793,30 @@ function tightestCoveringNode(sourceFile, start, end) {
741
793
  visit(sourceFile);
742
794
  return best;
743
795
  }
796
+ /**
797
+ * Locator text with insignificant whitespace removed: runs collapse to one
798
+ * space, and a member chain broken before its dot (`client\n .list(…)`) reads
799
+ * as `client.list(…)` (carrick#1162), the same rule the v1 inferrer applies.
800
+ */
801
+ function normalizeLocatorText(text) {
802
+ return text
803
+ .replace(/\s+/g, ' ')
804
+ .replace(/\s*(\?\.|\.)\s*/g, '$1')
805
+ .trim();
806
+ }
744
807
  function nodeByExpressionText(sourceFile, text, fromLine) {
745
- const wanted = text.replace(/\s+/g, ' ').trim();
808
+ const wanted = normalizeLocatorText(text);
746
809
  let best;
747
810
  const visit = (node) => {
748
811
  if (best)
749
812
  return;
750
813
  if (isPreferredTarget(node)) {
751
- const nodeText = node.getText(sourceFile).replace(/\s+/g, ' ').trim();
814
+ const nodeText = normalizeLocatorText(node.getText(sourceFile));
752
815
  if (nodeText === wanted) {
753
- const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
754
- if (fromLine === undefined || line >= fromLine) {
816
+ // On or after the line, or COVERING it: a chain broken before its dot
817
+ // starts a line above the line the analyzer reports for the call.
818
+ const endLine = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
819
+ if (fromLine === undefined || endLine >= fromLine) {
755
820
  best = node;
756
821
  return;
757
822
  }
@@ -60,6 +60,13 @@ export interface LiteralAnchorRequest {
60
60
  /** Verbatim TS type text (a bare symbol name or an inline object type). */
61
61
  type_text: string;
62
62
  anchor_origin: AnchorOrigin;
63
+ /**
64
+ * Repo-root-relative file the text was printed from, when there is one.
65
+ * It joins the analysis program, so a name the text prints bare (an enum
66
+ * member, a recursive reference) is found declared (carrick#1165). The
67
+ * record's `source_file` stays `<inline>`: the answer is still the text.
68
+ */
69
+ source_file?: string;
63
70
  }
64
71
  /**
65
72
  * Addressable handler: `export type A = Awaited<ReturnType<typeof
@@ -123,9 +130,14 @@ export type SelfCheckOutcome = 'ok' | 'allowlisted_external' | 'decayed_internal
123
130
  * honest value is `not_recorded` — never a guess.
124
131
  *
125
132
  * - `declared`: the captured declaration states `any`/`unknown` at this
126
- * position. Whatever put it there (an author annotation, or an emitter that
127
- * printed an unresolved value as `any`), it is baked into the emitted text
128
- * and no install re-resolves it.
133
+ * position, and the producer's own program had a type there too: an author
134
+ * annotation, not a failed resolution.
135
+ * - `unresolved_import`: the position is `any` in the emitted text because the
136
+ * producer's program could not resolve the type there — an import of a
137
+ * dependency that is not installed, or of a generated module that was never
138
+ * generated, on the scanned checkout (carrick#1164). The printer writes that
139
+ * placeholder as `any`, so the text alone reads like `declared`; the capture
140
+ * tells them apart on the source program at anchor time.
129
141
  * - `budget_exhausted`: the subtree was too deep or wide to finish inside the
130
142
  * capture walk's budget, so it is reported unverified rather than clean.
131
143
  * - `no_payload_evidence`: a handler returned a call whose callee has no
@@ -141,10 +153,17 @@ export type SelfCheckOutcome = 'ok' | 'allowlisted_external' | 'decayed_internal
141
153
  * position while its parsed output is concrete, which is what a coercion
142
154
  * declares (carrick#1101). The published type carries the output there, so
143
155
  * this finding describes what a caller may send, not the printed member.
156
+ * - `no_success_payload`: a route's handler sends only error or redirect
157
+ * responses (by the status each send states), or no success send carries a
158
+ * body a JSON contract can describe, so the route publishes no success body
159
+ * rather than an error body or a redirect location (carrick#1161).
160
+ * - `no_request_body`: the located request read is a validated part the
161
+ * route's validator binds that is not a body (a path parameter, a query), so
162
+ * the route states no request body contract there (carrick#1166).
144
163
  * - `not_recorded`: the position carries a top type and this layer has no
145
164
  * cause for it.
146
165
  */
147
- export type TypeProvenanceReason = 'declared' | 'budget_exhausted' | 'no_payload_evidence' | 'machinery_envelope' | 'coerced_input' | 'not_recorded';
166
+ export type TypeProvenanceReason = 'declared' | 'unresolved_import' | 'budget_exhausted' | 'no_payload_evidence' | 'machinery_envelope' | 'coerced_input' | 'no_success_payload' | 'no_request_body' | 'not_recorded';
148
167
  /**
149
168
  * One `any`/`unknown` finding inside a captured or inferred type, with its
150
169
  * position and its cause. Sorted by `path` wherever a list is emitted, so the
@@ -206,6 +225,22 @@ export interface CaptureAliasRecord {
206
225
  * Sorted by `path`; absent (not empty) when the walk found nothing.
207
226
  */
208
227
  any_provenance?: TypeProvenance[];
228
+ /**
229
+ * Internal module specifiers that fail to resolve anywhere in this alias's
230
+ * closure, as the emitted files write them (carrick#1165). The emitted
231
+ * tree is missing part of what the alias refers to, so a shape printed from
232
+ * it can name types nothing declares. Sorted; absent when there are none.
233
+ */
234
+ dangling_specifiers?: string[];
235
+ /**
236
+ * Identifiers this alias's printed text names that do not resolve where the
237
+ * surface declares it (carrick#1165): a literal anchor's text naming a type
238
+ * nothing declares, or a node-builder print reusing a source annotation
239
+ * whose own import did not resolve. Checked on the producer's program, so a global the
240
+ * program declares (a runtime or `@types` global) is never listed. Sorted;
241
+ * absent when there are none.
242
+ */
243
+ undeclared_names?: string[];
209
244
  }
210
245
  /** Aggregate fidelity metric, emitted per capture (one service). */
211
246
  export interface CaptureFidelity {
@@ -10,6 +10,8 @@
10
10
  * surface import error (probe lines 1-2)-> unverifiable
11
11
  * IsAny gate fired (TS2344) -> gate_caught_baked_any
12
12
  * IsUnknown/IsNever gate fired (TS2344) -> unverifiable
13
+ * IsVoid gate fired (TS2344) -> unverifiable (no body read)
14
+ * IsFormBody gate fired (TS2344) -> unverifiable (form-encoded body)
13
15
  * assignment-class error -> incompatible
14
16
  * no diagnostics -> compatible [lowest precedence]
15
17
  *
@@ -10,6 +10,8 @@
10
10
  * surface import error (probe lines 1-2)-> unverifiable
11
11
  * IsAny gate fired (TS2344) -> gate_caught_baked_any
12
12
  * IsUnknown/IsNever gate fired (TS2344) -> unverifiable
13
+ * IsVoid gate fired (TS2344) -> unverifiable (no body read)
14
+ * IsFormBody gate fired (TS2344) -> unverifiable (form-encoded body)
13
15
  * assignment-class error -> incompatible
14
16
  * no diagnostics -> compatible [lowest precedence]
15
17
  *
@@ -127,6 +129,32 @@ export function classifyPair(input) {
127
129
  ...notAFact(`the ${side} type is '${kind}'`),
128
130
  };
129
131
  }
132
+ // 4b. A side that states no contract (carrick#1162), below the decay gates so
133
+ // an `unknown` side is reported as `unknown`: a `void`/`undefined`
134
+ // response a call site reads no body from, and a form-encoded body whose
135
+ // fields are runtime appends.
136
+ const shapeGate = gateDiags
137
+ .map((d) => plan.gateLines.get(d.line))
138
+ .find((name) => name.endsWith(':void') || name.endsWith(':form'));
139
+ if (shapeGate) {
140
+ const { side, kind } = sideForGate(shapeGate, plan);
141
+ if (kind === 'void') {
142
+ return {
143
+ ...base,
144
+ bucket: 'unverifiable',
145
+ gate: `${side}:void`,
146
+ diagnostic: `the ${side} type is 'void' (it reads no body), so there is no contract to verify.`,
147
+ ...notAFact(`the ${side} type is 'void'`),
148
+ };
149
+ }
150
+ return {
151
+ ...base,
152
+ bucket: 'unverifiable',
153
+ gate: `${side}:form`,
154
+ diagnostic: `the ${side} sends a form-encoded body, whose fields are appended at runtime and cannot be compared with a declared shape.`,
155
+ ...notAFact(`the ${side} sends a form-encoded body`),
156
+ };
157
+ }
130
158
  // 5. Assignment-class error on the value assignment line -> incompatible.
131
159
  const assignDiag = probeDiags.find((d) => d.line === plan.assignmentLine && ASSIGNMENT_CODES.has(d.code));
132
160
  if (assignDiag) {
@@ -84,7 +84,7 @@ function walkImportedAlias(file, localName, program, checker) {
84
84
  const type = checker.getDeclaredTypeOfSymbol(target);
85
85
  if (!type)
86
86
  return undefined;
87
- return findDisqualifyingTopTypes(type, program, checker, element.name).map(provenanceOf);
87
+ return findDisqualifyingTopTypes(type, program, checker, element.name).map((finding) => provenanceOf(finding));
88
88
  }
89
89
  }
90
90
  return undefined;
@@ -43,7 +43,7 @@ export declare function fnv1a(input: string): string;
43
43
  * path, so it is byte-stable across runs. */
44
44
  export declare function pairId(spec: CheckPairSpec): string;
45
45
  /** Names each gate line so a TS2344 can be attributed to a specific side+kind. */
46
- export type GateName = 'sent:any' | 'sent:unknown' | 'sent:never' | 'expected:any' | 'expected:unknown' | 'expected:never';
46
+ export type GateName = 'sent:any' | 'sent:unknown' | 'sent:never' | 'sent:void' | 'sent:form' | 'expected:any' | 'expected:unknown' | 'expected:never' | 'expected:void';
47
47
  export interface ProbePlan {
48
48
  pairId: string;
49
49
  spec: CheckPairSpec;
@@ -95,6 +95,22 @@ export function buildProbe(spec, packageOf) {
95
95
  gateLines.set(push(`type _G_expected_any = Assert<Not<IsAny<Expected>>>;`), 'expected:any');
96
96
  gateLines.set(push(`type _G_expected_unknown = Assert<Not<IsUnknown<Expected>>>;`), 'expected:unknown');
97
97
  gateLines.set(push(`type _G_expected_never = Assert<Not<IsNever<Expected>>>;`), 'expected:never');
98
+ // carrick#1162: `void`/`undefined` is what a call site that reads no body
99
+ // types its response as (a wrapper returning `Promise<void>`). It states no
100
+ // contract, so no counterparty shape can be judged against it.
101
+ // `any` is excluded first: `[any] extends [void]` holds, and an `any` side
102
+ // must keep its own top-type reason, never read as "reads no body".
103
+ push(`type IsVoid<T> = 0 extends 1 & T ? false : [T] extends [never] ? false : [T] extends [void] ? true : false;`);
104
+ gateLines.set(push(`type _G_sent_void = Assert<Not<IsVoid<Sent>>>;`), 'sent:void');
105
+ gateLines.set(push(`type _G_expected_void = Assert<Not<IsVoid<Expected>>>;`), 'expected:void');
106
+ // carrick#1162: a form-encoded request body (the platform `FormData` or
107
+ // `URLSearchParams`) carries its fields as runtime appends, which no type
108
+ // records, so a field-by-field comparison against a declared shape reads
109
+ // every such body as missing all of its fields.
110
+ if (spec.protocol === 'http' && spec.type_kind === 'request') {
111
+ push(`type IsFormBody<T> = 0 extends 1 & T ? false : [T] extends [never] ? false : [T] extends [FormData | URLSearchParams] ? true : false;`);
112
+ gateLines.set(push(`type _G_sent_form = Assert<Not<IsFormBody<Sent>>>;`), 'sent:form');
113
+ }
98
114
  push(`declare const sent: Sent;`);
99
115
  let assignmentLine;
100
116
  if (spec.protocol === 'graphql') {
@@ -44,19 +44,46 @@ export type DeepTopType = Pick<TypeProvenance, 'kind' | 'path'>;
44
44
  * void` safely accepts a stricter counterparty), so it is not a masked
45
45
  * mismatch and demoting it would over-demote a sound shape;
46
46
  * - TypeScript's unresolved-reference `error` placeholder (`intrinsicName ===
47
- * 'error'`) is excluded (see `flagOf`): it heals when the check installs the
47
+ * 'error'`) is excluded (see `disqualifyingFlag`): it heals when the check installs the
48
48
  * pinned external, so it is a healable decay, not an author-baked `any`.
49
49
  * A type the walk cannot cheaply finish is NOT flagged — over-demoting a
50
50
  * legitimately fully-resolved type is the failure mode this guard must not have.
51
51
  */
52
52
  export declare function findDisqualifyingTopTypes(root: ts.Type, program: ts.Program, checker: ts.TypeChecker, location: ts.Node): DeepTopType[];
53
+ /**
54
+ * Member paths at which `root` holds TypeScript's unresolved-reference
55
+ * placeholder (the `error` intrinsic) rather than a type (carrick#1164).
56
+ *
57
+ * Run on the SOURCE program, where a member typed through an import that did
58
+ * not resolve still carries the placeholder. Once printed into the stub the
59
+ * placeholder is the keyword `any`, indistinguishable from an author's `any`,
60
+ * so this is the only point at which the two causes can be told apart. Same
61
+ * walk, same budget and the same path notation as the self-check's walk over
62
+ * the emitted text, so a path found here names the member a self-check finding
63
+ * names. A walk that runs out of budget reports what it found before it did.
64
+ */
65
+ export declare function findUnresolvedPlaceholders(root: ts.Type, program: ts.Program, checker: ts.TypeChecker, location: ts.Node): string[];
66
+ /** What an anchor's SOURCE program could not resolve (carrick#1164). */
67
+ export interface UnresolvedAtAnchor {
68
+ /** Member paths holding the unresolved-reference placeholder. */
69
+ paths: readonly string[];
70
+ /**
71
+ * Module specifiers, as the source wrote them, that did not resolve from the
72
+ * anchor's file or a module it reaches. Internal specifiers first. May be
73
+ * empty: a name the program never declared leaves the same placeholder.
74
+ */
75
+ specifiers: readonly string[];
76
+ }
53
77
  /**
54
78
  * Turn a deep finding into the published provenance entry (carrick#376).
55
79
  *
56
80
  * The self-check reads EMITTED declaration text, so a top type it finds is
57
- * text: whatever produced it — an author annotation, or an emitter that
58
- * printed a value it could not resolve — no install re-resolves it. That is
59
- * `declared`, and saying so is more useful than the bare `any` a reader gets
60
- * today. The one other cause it can distinguish is its own budget.
81
+ * text: an author annotation and an emitter that printed an unresolved value
82
+ * both read `any` there. The anchor's source program can still tell them apart
83
+ * (`findUnresolvedPlaceholders`), and when it recorded the finding's path as a
84
+ * placeholder the cause is `unresolved_import`: a dependency or a generated
85
+ * module was missing on the scanned checkout, which installing or generating
86
+ * it fixes (carrick#1164). Anything else at that position is `declared`. The
87
+ * one other cause the walk itself can distinguish is its own budget.
61
88
  */
62
- export declare function provenanceOf(finding: DeepTopType): TypeProvenance;
89
+ export declare function provenanceOf(finding: DeepTopType, unresolved?: UnresolvedAtAnchor): TypeProvenance;
@@ -43,12 +43,38 @@ const MAX_DEEP_FINDINGS = 32;
43
43
  * void` safely accepts a stricter counterparty), so it is not a masked
44
44
  * mismatch and demoting it would over-demote a sound shape;
45
45
  * - TypeScript's unresolved-reference `error` placeholder (`intrinsicName ===
46
- * 'error'`) is excluded (see `flagOf`): it heals when the check installs the
46
+ * 'error'`) is excluded (see `disqualifyingFlag`): it heals when the check installs the
47
47
  * pinned external, so it is a healable decay, not an author-baked `any`.
48
48
  * A type the walk cannot cheaply finish is NOT flagged — over-demoting a
49
49
  * legitimately fully-resolved type is the failure mode this guard must not have.
50
50
  */
51
51
  export function findDisqualifyingTopTypes(root, program, checker, location) {
52
+ return walkTopTypes(root, program, checker, location, disqualifyingFlag);
53
+ }
54
+ /**
55
+ * Member paths at which `root` holds TypeScript's unresolved-reference
56
+ * placeholder (the `error` intrinsic) rather than a type (carrick#1164).
57
+ *
58
+ * Run on the SOURCE program, where a member typed through an import that did
59
+ * not resolve still carries the placeholder. Once printed into the stub the
60
+ * placeholder is the keyword `any`, indistinguishable from an author's `any`,
61
+ * so this is the only point at which the two causes can be told apart. Same
62
+ * walk, same budget and the same path notation as the self-check's walk over
63
+ * the emitted text, so a path found here names the member a self-check finding
64
+ * names. A walk that runs out of budget reports what it found before it did.
65
+ */
66
+ export function findUnresolvedPlaceholders(root, program, checker, location) {
67
+ return walkTopTypes(root, program, checker, location, (t) => isErrorPlaceholder(t) ? 'any' : undefined)
68
+ .filter((finding) => finding.kind !== 'budget_exhausted')
69
+ .map((finding) => finding.path);
70
+ }
71
+ /** TypeScript's unresolved-reference placeholder: `TypeFlags.Any` with the
72
+ * internal `intrinsicName === 'error'` (stable since TS 1.x; see `anchors.ts`). */
73
+ function isErrorPlaceholder(t) {
74
+ return ((t.flags & ts.TypeFlags.Any) !== 0 &&
75
+ t.intrinsicName === 'error');
76
+ }
77
+ function walkTopTypes(root, program, checker, location, flagOf) {
52
78
  // Cover v1's inline-expander reach with margin so this structural walk is a
53
79
  // genuine superset of v1's text-scan disqualifier AT DEPTH: anything v1 could
54
80
  // expand-and-flag as `any`/`unknown`, this walk reaches too. v1's expander
@@ -63,28 +89,6 @@ export function findDisqualifyingTopTypes(root, program, checker, location) {
63
89
  const MAX_VISITED = 4096;
64
90
  const seen = new Set();
65
91
  let visited = 0;
66
- const flagOf = (t) => {
67
- if (t.flags & ts.TypeFlags.Any) {
68
- // TypeScript's unresolved-reference placeholder (e.g. `import('ext').Foo`
69
- // on a bare checkout) carries `TypeFlags.Any` but `intrinsicName ===
70
- // 'error'` — NOT an author-baked `any`. It resolves to the real type once
71
- // the check phase installs the pinned external, so it must not count as a
72
- // disqualifier: treating it as `any` would demote a healable external
73
- // reference. Genuine author `any` carries `intrinsicName === 'any'`.
74
- // (`intrinsicName` is internal but stable since TS 1.x — same standing as
75
- // its use in anchors.ts.)
76
- //
77
- // The `error` placeholder also stands in for NON-healable causes (TS2304
78
- // undefined name, TS2315 wrong-arity generic, a dangling internal
79
- // specifier). Excluding those here is not a hole: each emits a diagnostic
80
- // in the alias's own closure, so the closure-failure classification
81
- // (`internalFailure` -> decayed_internal) or the check-phase POISON rule
82
- // — NOT this deep walk — is their backstop, and both fail closed.
83
- const name = t.intrinsicName;
84
- return name === 'error' ? undefined : 'any';
85
- }
86
- return t.flags & ts.TypeFlags.Unknown ? 'unknown' : undefined;
87
- };
88
92
  // Findings accumulate rather than short-circuiting: the FIRST is what the
89
93
  // check phase pre-gates on (so the verdict is identical to the
90
94
  // stop-at-first walk this replaces), and the rest answer "which fields are
@@ -216,16 +220,44 @@ export function findDisqualifyingTopTypes(root, program, checker, location) {
216
220
  rest.sort((a, b) => a.path.localeCompare(b.path));
217
221
  return [head, ...rest];
218
222
  }
223
+ /** The disqualifier the self-check and the check phase gate on (see
224
+ * `findDisqualifyingTopTypes`). */
225
+ function disqualifyingFlag(t) {
226
+ if (t.flags & ts.TypeFlags.Any) {
227
+ // TypeScript's unresolved-reference placeholder (e.g. `import('ext').Foo`
228
+ // on a bare checkout) carries `TypeFlags.Any` but `intrinsicName ===
229
+ // 'error'` — NOT an author-baked `any`. It resolves to the real type once
230
+ // the check phase installs the pinned external, so it must not count as a
231
+ // disqualifier: treating it as `any` would demote a healable external
232
+ // reference. Genuine author `any` carries `intrinsicName === 'any'`.
233
+ // (`intrinsicName` is internal but stable since TS 1.x — same standing as
234
+ // its use in anchors.ts.)
235
+ //
236
+ // The `error` placeholder also stands in for NON-healable causes (TS2304
237
+ // undefined name, TS2315 wrong-arity generic, a dangling internal
238
+ // specifier). Excluding those here is not a hole: each emits a diagnostic
239
+ // in the alias's own closure, so the closure-failure classification
240
+ // (`internalFailure` -> decayed_internal) or the check-phase POISON rule
241
+ // — NOT this deep walk — is their backstop, and both fail closed.
242
+ return isErrorPlaceholder(t) ? undefined : 'any';
243
+ }
244
+ return t.flags & ts.TypeFlags.Unknown ? 'unknown' : undefined;
245
+ }
246
+ /** How many unresolved specifiers a detail names before it counts the rest. */
247
+ const MAX_NAMED_SPECIFIERS = 3;
219
248
  /**
220
249
  * Turn a deep finding into the published provenance entry (carrick#376).
221
250
  *
222
251
  * The self-check reads EMITTED declaration text, so a top type it finds is
223
- * text: whatever produced it — an author annotation, or an emitter that
224
- * printed a value it could not resolve — no install re-resolves it. That is
225
- * `declared`, and saying so is more useful than the bare `any` a reader gets
226
- * today. The one other cause it can distinguish is its own budget.
252
+ * text: an author annotation and an emitter that printed an unresolved value
253
+ * both read `any` there. The anchor's source program can still tell them apart
254
+ * (`findUnresolvedPlaceholders`), and when it recorded the finding's path as a
255
+ * placeholder the cause is `unresolved_import`: a dependency or a generated
256
+ * module was missing on the scanned checkout, which installing or generating
257
+ * it fixes (carrick#1164). Anything else at that position is `declared`. The
258
+ * one other cause the walk itself can distinguish is its own budget.
227
259
  */
228
- export function provenanceOf(finding) {
260
+ export function provenanceOf(finding, unresolved) {
229
261
  if (finding.kind === 'budget_exhausted') {
230
262
  return {
231
263
  path: finding.path,
@@ -234,6 +266,14 @@ export function provenanceOf(finding) {
234
266
  detail: 'the type is too deep or wide to verify within the capture budget here, so it is reported unverified rather than assumed clean',
235
267
  };
236
268
  }
269
+ if (finding.kind === 'any' && unresolved?.paths.includes(finding.path)) {
270
+ return {
271
+ path: finding.path,
272
+ kind: finding.kind,
273
+ reason: 'unresolved_import',
274
+ detail: unresolvedDetail(unresolved.specifiers),
275
+ };
276
+ }
237
277
  return {
238
278
  path: finding.path,
239
279
  kind: finding.kind,
@@ -241,3 +281,16 @@ export function provenanceOf(finding) {
241
281
  detail: `the captured declaration states '${finding.kind}' at this position, so no counterparty shape can disagree with it`,
242
282
  };
243
283
  }
284
+ function unresolvedDetail(specifiers) {
285
+ const lead = "the type at this position did not resolve on the scanned checkout, so the compiler printed a placeholder 'any' rather than a declared type";
286
+ if (specifiers.length === 0)
287
+ return lead;
288
+ const named = specifiers
289
+ .slice(0, MAX_NAMED_SPECIFIERS)
290
+ .map((specifier) => `'${specifier}'`)
291
+ .join(', ');
292
+ const more = specifiers.length > MAX_NAMED_SPECIFIERS
293
+ ? ` and ${specifiers.length - MAX_NAMED_SPECIFIERS} more`
294
+ : '';
295
+ return `${lead}; unresolved imports reachable from the anchor: ${named}${more}`;
296
+ }