carrick 0.3.72 → 0.3.75

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 (38) hide show
  1. package/dist/contract.d.ts +11 -2
  2. package/dist/contract.js +5 -0
  3. package/dist/contract.js.map +1 -1
  4. package/dist/init/run.js +1 -1
  5. package/dist/init/run.js.map +1 -1
  6. package/dist/render.js +11 -3
  7. package/dist/render.js.map +1 -1
  8. package/package.json +6 -6
  9. package/sidecar/dist/src/capture/anchors.d.ts +15 -0
  10. package/sidecar/dist/src/capture/anchors.js +70 -5
  11. package/sidecar/dist/src/capture/api.d.ts +39 -4
  12. package/sidecar/dist/src/capture/check-classify.d.ts +2 -0
  13. package/sidecar/dist/src/capture/check-classify.js +28 -0
  14. package/sidecar/dist/src/capture/check-deep.js +1 -1
  15. package/sidecar/dist/src/capture/check-probe.d.ts +1 -1
  16. package/sidecar/dist/src/capture/check-probe.js +16 -0
  17. package/sidecar/dist/src/capture/deep-walk.d.ts +33 -6
  18. package/sidecar/dist/src/capture/deep-walk.js +81 -28
  19. package/sidecar/dist/src/capture/index.js +16 -6
  20. package/sidecar/dist/src/capture/installed-package.d.ts +58 -0
  21. package/sidecar/dist/src/capture/installed-package.js +311 -0
  22. package/sidecar/dist/src/capture/lockfile.d.ts +8 -0
  23. package/sidecar/dist/src/capture/lockfile.js +1 -1
  24. package/sidecar/dist/src/capture/node-builder.d.ts +27 -0
  25. package/sidecar/dist/src/capture/node-builder.js +107 -1
  26. package/sidecar/dist/src/capture/paths-rewrite.d.ts +23 -6
  27. package/sidecar/dist/src/capture/paths-rewrite.js +43 -11
  28. package/sidecar/dist/src/capture/self-check.js +12 -3
  29. package/sidecar/dist/src/capture/unresolved.d.ts +28 -0
  30. package/sidecar/dist/src/capture/unresolved.js +107 -0
  31. package/sidecar/dist/src/type-inferrer.d.ts +108 -11
  32. package/sidecar/dist/src/type-inferrer.js +513 -90
  33. package/sidecar/dist/src/type-structural-expander.d.ts +23 -2
  34. package/sidecar/dist/src/type-structural-expander.js +55 -17
  35. package/sidecar/dist/src/type-text-canonicalizer.d.ts +14 -0
  36. package/sidecar/dist/src/type-text-canonicalizer.js +23 -2
  37. package/sidecar/dist/src/validators.d.ts +10 -0
  38. package/sidecar/dist/src/validators.js +1 -0
@@ -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
+ }
@@ -35,6 +35,7 @@ import { entryRelativeSpecifier, resolveAnchor } from './anchors.js';
35
35
  import { findAugmentationFiles } from './augmentations.js';
36
36
  import { installedVersions, lockfileVersions } from './lockfile.js';
37
37
  import { rewriteEmittedSpecifiers } from './paths-rewrite.js';
38
+ import { typesPackageOf, withInstalledPackages } from './installed-package.js';
38
39
  import { selfCheckStub } from './self-check.js';
39
40
  import { collectSpecifiers, isRelative, packageNameOf } from './specifiers.js';
40
41
  import { DenoProject, findDenoConfig } from './deno-project.js';
@@ -304,13 +305,14 @@ export function captureStub(opts) {
304
305
  catch (err) {
305
306
  return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
306
307
  }
307
- const specifierRewrites = denoRewrites + rewriteEmittedSpecifiers({
308
+ const rewritten = rewriteEmittedSpecifiers({
308
309
  typesDir,
309
310
  files: emittedFiles,
310
311
  options: parsed.options,
311
312
  configPath,
312
313
  entryDir,
313
314
  });
315
+ const specifierRewrites = denoRewrites + rewritten.rewrites;
314
316
  // ---- Pin external deps: installed node_modules first, lockfile fallback ----
315
317
  // Externals are collected AFTER the rewrite pass: a rewritten paths
316
318
  // specifier is internal, not a dependency.
@@ -335,13 +337,20 @@ export function captureStub(opts) {
335
337
  const lockVersions = lockfileVersions(repoRoot);
336
338
  for (const name of Object.keys(deno?.pinned ?? {}))
337
339
  externalSpecs.add(name);
340
+ // A package an absolute specifier was rewritten into carries the version
341
+ // installed at that path (#1174); it may be a transitive the repo root
342
+ // neither installs by name nor locks.
343
+ for (const name of Object.keys(rewritten.pins))
344
+ externalSpecs.add(name);
338
345
  const pinned = {};
339
346
  const unpinned = [];
340
347
  for (const name of [...externalSpecs].sort()) {
341
- const version = deno?.pinned[name] ?? installed.get(name) ?? lockVersions.get(name);
348
+ const version = deno?.pinned[name] ?? rewritten.pins[name] ?? installed.get(name) ?? lockVersions.get(name);
342
349
  if (version)
343
350
  pinned[name] = version;
344
- else
351
+ // A runtime name whose declarations come from a rewritten `@types/*`
352
+ // package resolves through that pin.
353
+ else if (!rewritten.pins[typesPackageOf(name)])
345
354
  unpinned.push(name);
346
355
  }
347
356
  const dependencyRoot = deno?.config.workspaceRoot ?? repoRoot;
@@ -369,7 +378,9 @@ export function captureStub(opts) {
369
378
  pinned,
370
379
  bareCheckout,
371
380
  repoRoot: dependencyRoot,
372
- compilerHost: deno ? options => deno.host(options) : undefined,
381
+ compilerHost: deno || Object.keys(rewritten.installs).length > 0
382
+ ? (options) => withInstalledPackages(options, rewritten.installs, deno ? deno.host(options) : undefined)
383
+ : undefined,
373
384
  });
374
385
  const fidelity = computeFidelity(aliases);
375
386
  fs.writeFileSync(path.join(stubDir, 'carrick-manifest.json'), JSON.stringify({
@@ -478,8 +489,7 @@ function resolveAnchors(opts, parsed, ctx, deno) {
478
489
  try {
479
490
  const anchorSources = [
480
491
  ...new Set(opts.anchors
481
- .filter((a) => a.kind !== 'literal')
482
- .map((a) => path.join(ctx.repoRoot, a.source_file))),
492
+ .flatMap((a) => (a.source_file ? [path.join(ctx.repoRoot, a.source_file)] : []))),
483
493
  ].filter((f) => fs.existsSync(f));
484
494
  const options = {
485
495
  ...parsed.options,
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Map an absolute path into an installed package onto the specifier a
3
+ * consumer of that package would write (carrick#1174).
4
+ *
5
+ * The v1 printer and the node builder name an out-of-scope type by the
6
+ * absolute path of its declaration file when no bare specifier reaches it
7
+ * from where they print. Inside a capture stub that path is both private (the
8
+ * checkout root, or the home directory of a runtime's npm cache) and dead:
9
+ * the check workspace resolves packages from the stub's pinned dependencies,
10
+ * never from the scanning machine's disk.
11
+ *
12
+ * The replacement is decided against the package itself, installed alone at
13
+ * `node_modules/<name>`, which is exactly what the check workspace sees:
14
+ * 1. an `exports` entry (or, without `exports`, the types entry or the file's
15
+ * subpath) that resolves to the same file under bundler resolution;
16
+ * 2. failing that, an exported entry whose module exports the imported name
17
+ * from that same file (a type in an unexported file, re-exported by the
18
+ * package root);
19
+ * 3. failing both, the subpath as written, so the self-check reports the
20
+ * specifier as unresolvable rather than the stub leaking the path.
21
+ *
22
+ * Seam: node builtins, `typescript`, and this bundle only.
23
+ */
24
+ import ts from 'typescript';
25
+ export interface InstalledPackageSpecifier {
26
+ /** Bare specifier: public package name plus subpath, e.g. `pkg/sub`. */
27
+ specifier: string;
28
+ /** The package's own name and real install directory (never written out). */
29
+ install: {
30
+ name: string;
31
+ root: string;
32
+ };
33
+ /** The package the path landed in and its installed version, when published. */
34
+ pin?: {
35
+ name: string;
36
+ version: string;
37
+ };
38
+ }
39
+ /**
40
+ * The `@types/*` package that serves a bare name's declarations, for callers
41
+ * deciding whether a runtime name is covered by a pinned types package.
42
+ */
43
+ export declare function typesPackageOf(name: string): string;
44
+ /**
45
+ * A compiler host that also resolves the packages an absolute specifier was
46
+ * rewritten into, each from its own install directory. The capture's
47
+ * self-check compiles the stub against the producer's `node_modules`, which
48
+ * does not name a transitive package at its root; the check workspace does,
49
+ * because the stub pins it. Without this the self-check would fail a
50
+ * specifier the check resolves.
51
+ */
52
+ export declare function withInstalledPackages(options: ts.CompilerOptions, installs: Record<string, string>, base?: ts.CompilerHost): ts.CompilerHost;
53
+ /**
54
+ * The bare specifier for an absolute path into an installed package, or
55
+ * undefined when the path does not land in one. `importedName` is the first
56
+ * name read off the import (`import("...").Name`), when the text has one.
57
+ */
58
+ export declare function installedPackageSpecifier(absoluteSpecifier: string, importedName?: string): InstalledPackageSpecifier | undefined;