carrick 0.3.104 → 0.3.106

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 (33) hide show
  1. package/package.json +6 -6
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/sidecar/dist/src/capture/anchors.d.ts +13 -0
  4. package/sidecar/dist/src/capture/anchors.js +229 -37
  5. package/sidecar/dist/src/capture/api.d.ts +64 -4
  6. package/sidecar/dist/src/capture/check-classify.d.ts +10 -1
  7. package/sidecar/dist/src/capture/check-classify.js +23 -12
  8. package/sidecar/dist/src/capture/check-fields.js +4 -6
  9. package/sidecar/dist/src/capture/check-probe.js +16 -6
  10. package/sidecar/dist/src/capture/check-scrub.d.ts +3 -0
  11. package/sidecar/dist/src/capture/check-scrub.js +8 -4
  12. package/sidecar/dist/src/capture/check.js +75 -49
  13. package/sidecar/dist/src/capture/deep-walk.d.ts +20 -0
  14. package/sidecar/dist/src/capture/deep-walk.js +51 -11
  15. package/sidecar/dist/src/capture/guarded-fs.d.ts +5 -1
  16. package/sidecar/dist/src/capture/guarded-fs.js +23 -1
  17. package/sidecar/dist/src/capture/index.js +14 -2
  18. package/sidecar/dist/src/capture/member-name.d.ts +20 -0
  19. package/sidecar/dist/src/capture/member-name.js +24 -0
  20. package/sidecar/dist/src/capture/self-check.js +50 -9
  21. package/sidecar/dist/src/capture/service-config.d.ts +2 -0
  22. package/sidecar/dist/src/capture/service-config.js +1 -1
  23. package/sidecar/dist/src/failure-path.d.ts +67 -0
  24. package/sidecar/dist/src/failure-path.js +236 -0
  25. package/sidecar/dist/src/printed-names.d.ts +43 -0
  26. package/sidecar/dist/src/printed-names.js +186 -0
  27. package/sidecar/dist/src/retype.js +60 -99
  28. package/sidecar/dist/src/type-inferrer.d.ts +35 -3
  29. package/sidecar/dist/src/type-inferrer.js +208 -13
  30. package/sidecar/dist/src/type-structural-expander.js +10 -1
  31. package/sidecar/dist/src/types.d.ts +24 -0
  32. package/sidecar/dist/src/validators.d.ts +76 -0
  33. package/sidecar/dist/src/validators.js +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carrick",
3
- "version": "0.3.104",
3
+ "version": "0.3.106",
4
4
  "description": "Maps your entire TypeScript codebase across services and repositories, giving AI agents full context on existing types, routes, and function behaviours over MCP before they write duplicate or breaking code.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -58,11 +58,11 @@
58
58
  "zod": "^3.23.0"
59
59
  },
60
60
  "optionalDependencies": {
61
- "@carrick-tools/cli-darwin-arm64": "0.3.104",
62
- "@carrick-tools/cli-darwin-x64": "0.3.104",
63
- "@carrick-tools/cli-linux-arm64": "0.3.104",
64
- "@carrick-tools/cli-linux-x64": "0.3.104",
65
- "@carrick-tools/cli-win32-x64": "0.3.104"
61
+ "@carrick-tools/cli-darwin-arm64": "0.3.106",
62
+ "@carrick-tools/cli-darwin-x64": "0.3.106",
63
+ "@carrick-tools/cli-linux-arm64": "0.3.106",
64
+ "@carrick-tools/cli-linux-x64": "0.3.106",
65
+ "@carrick-tools/cli-win32-x64": "0.3.106"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@types/node": "^24.13.3",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "carrick",
3
3
  "description": "Claude Code plugin for Carrick, which indexes TypeScript codebases across service and repository boundaries. After each edit it adds the file's routes, calls, and cross-service type mismatches to the session, and it registers Carrick's language server. It pairs with the Carrick MCP server, which lets agents search functions by intent rather than name.",
4
- "version": "0.3.104",
4
+ "version": "0.3.106",
5
5
  "author": {
6
6
  "name": "Carrick",
7
7
  "email": "hello@carrick.tools"
@@ -49,6 +49,12 @@ export interface ResolvedAnchor {
49
49
  * anchor's type holds no unresolved placeholder.
50
50
  */
51
51
  unresolved?: UnresolvedAtAnchor;
52
+ /**
53
+ * carrick#1446: set on a demotion whose alias text names a module the
54
+ * declaration emit skipped. The text dangles in the emitted tree, which the
55
+ * record states for a literal anchor, whose text is what the index serves.
56
+ */
57
+ namesUnemittedModule?: true;
52
58
  }
53
59
  /** Repo-root-relative source file -> extensionless specifier from entryDir. */
54
60
  export declare function entryRelativeSpecifier(entryDir: string, repoRoot: string, sourceFile: string): string;
@@ -67,6 +73,13 @@ export declare function resolveAnchor(program: ts.Program, request: CaptureAncho
67
73
  * these resolves through that module instead of dangling in the entry.
68
74
  */
69
75
  siblingSymbolSpecs?: Map<string, string>;
76
+ /**
77
+ * The file a module specifier resolves to from the surface entry, under
78
+ * the analysis program's own resolution (carrick#1789). A literal's name
79
+ * is imported through a package specifier only when the entry reaches the
80
+ * same module by it.
81
+ */
82
+ resolveFromEntry?: (specifier: string) => string | undefined;
70
83
  }): ResolvedAnchor;
71
84
  /**
72
85
  * Locate the target node: tightest expression covering the byte span when
@@ -8,6 +8,8 @@ import ts from 'typescript';
8
8
  import * as path from 'node:path';
9
9
  import { printTypeForDestination, substituteUndeclaredNames, undeclaredNamesIn, } from './node-builder.js';
10
10
  import { typeIsOrContainsMachinery } from './machinery.js';
11
+ import { installedPackageSpecifier } from './installed-package.js';
12
+ import { realPath } from './service-config.js';
11
13
  import { unresolvedAtAnchor, unresolvedSpecifiersReachableFrom, } from './unresolved.js';
12
14
  /** Repo-root-relative source file -> extensionless specifier from entryDir. */
13
15
  export function entryRelativeSpecifier(entryDir, repoRoot, sourceFile) {
@@ -56,10 +58,13 @@ export function resolveAnchor(program, request, args) {
56
58
  // sibling symbol anchor imports is resolved by that import.
57
59
  // carrick#1774: the text names types as they read where it was printed,
58
60
  // so a name its source file means by a repo module's export is imported
59
- // from that module (a string-union alias printed bare read `any`).
61
+ // from that module (a string-union alias printed bare read `any`), and
62
+ // (carrick#1789) a name it imports from a package is imported from that
63
+ // package. carrick#1836: a name the source file cannot see is imported
64
+ // from the module the inference recorded it was printed for.
60
65
  const scoped = siblingSpec
61
66
  ? undefined
62
- : qualifyNamesFromSource(text, program, request.source_file, args);
67
+ : qualifyNamesFromSource(text, program, request.source_file, request.printed_names, args);
63
68
  const scopedText = scoped ?? text;
64
69
  // carrick#1377: rewrite what nothing declares to `unknown` in place, so
65
70
  // one member typed by a module the checkout does not have stops taking
@@ -185,10 +190,33 @@ export function resolveAnchor(program, request, args) {
185
190
  }
186
191
  return finishInferAnchor(program, sourceFile, request, param, args.placeholder, undefined, args.repoRoot);
187
192
  }
188
- let located = locateNode(sourceFile, request);
189
- if (!located) {
193
+ const found = locate(sourceFile, request);
194
+ if (!found) {
190
195
  return demote(locatorFailureReason(request));
191
196
  }
197
+ // carrick#1785: the line fallback takes the first expression on the line,
198
+ // and a declaration's NAME is an expression to `ts.isExpression`. On a line
199
+ // that declares something, that name is all the fallback finds, and its type
200
+ // is the declared entity's own (a whole alias, a function, a class), never a
201
+ // payload. The line names the declaration, so the anchor abstains: an
202
+ // abstain, not a demotion, because the backfill has nothing better to
203
+ // re-anchor it with (carrick#766). It does not walk on to the next node
204
+ // either, which on a function's line is its first parameter.
205
+ if (found.by === 'line') {
206
+ const declaration = declarationNamedBy(found.node);
207
+ if (declaration) {
208
+ return {
209
+ request,
210
+ aliasText: 'unknown',
211
+ serialization: 'structural_fallback',
212
+ abstainReason: `the line fallback resolved the name of ` +
213
+ `${describeNode(sourceFile, request.source_file, declaration)}; a ` +
214
+ `declaration's name is not a payload, so the anchor abstains rather ` +
215
+ `than publish the declared entity's own type`,
216
+ };
217
+ }
218
+ }
219
+ let located = found.node;
192
220
  // carrick#1162: a serialised body is the JSON of its argument. The call's own
193
221
  // `string` result is never the payload's contract, and publishing it reads
194
222
  // incompatible against every object-typed counterparty.
@@ -252,17 +280,18 @@ function finishInferAnchor(program, sourceFile, request, located, placeholder, r
252
280
  // here.
253
281
  //
254
282
  // A line alone names nothing. `firstExpressionOnLine` picks whatever comes
255
- // first on the line, which on a re-export statement is the first exported
256
- // binding. When THAT resolves to a top type the capture holds no payload and
257
- // no type, and `export type <alias> = any;` states "a type was inferred and
258
- // it collapsed" — a claim the scan cannot back. The honest word is `unknown`
259
- // ("no contract stated here"), with the node the line resolved as the reason.
283
+ // first on the line. When THAT resolves to a top type the capture holds no
284
+ // payload and no type, and `export type <alias> = any;` states "a type was
285
+ // inferred and it collapsed" — a claim the scan cannot back. The honest word
286
+ // is `unknown` ("no contract stated here"), with the node the line resolved
287
+ // as the reason.
260
288
  //
261
289
  // Live shape: a route whose handlers are built by a framework factory and
262
290
  // re-exported at the bottom of the file. Both of the file's operations
263
- // anchor at the export statement, the v1 walk abstains there (carrick#771),
264
- // and the alias falls to this line-only locator, which resolves the first
265
- // exported binding's identifier.
291
+ // anchor at the export statement and the v1 walk abstains there
292
+ // (carrick#771). The export line itself now abstains earlier, as a
293
+ // declaration's name (carrick#1785); this guard still holds for a line whose
294
+ // first node is a value that decayed, such as the factory call's binding.
266
295
  if (isTopType(type) && isLineOnly(request)) {
267
296
  return {
268
297
  request,
@@ -366,15 +395,25 @@ function substituteUndeclaredNamesInText(text, program, destination) {
366
395
  * member that reads `any`) or a global of the same name (`Notification`).
367
396
  * Each reference whose name the source resolves to a type that a module
368
397
  * inside the repo exports becomes `import('<module>').<export>`, with its
369
- * qualifier and type arguments kept. Anything else is left as written: a
370
- * global, a name only a function body declares, an unexported local, and a
371
- * type declared outside the repo or under `node_modules`, which the stub does
372
- * not ship.
398
+ * qualifier and type arguments kept. A name the source imports from an
399
+ * installed package becomes `import('<package specifier>').<export>`
400
+ * (carrick#1789): the stub ships no `node_modules`, but it pins the packages
401
+ * its surface imports, and the check phase installs them. Anything else is
402
+ * left as written: a global, a name only a function body declares, an
403
+ * unexported local, and a type declared outside the repo that no package
404
+ * import reaches.
405
+ *
406
+ * carrick#1836: a name the source file cannot see at all was printed without
407
+ * its scope (the inferrer's structural printer writes names bare). When the
408
+ * inference recorded one declaration for it (`printed`), the name is imported
409
+ * from that declaration's module, under the same repo rule. A name recorded
410
+ * for two declarations is left as written: the text no longer says which one
411
+ * a position meant.
373
412
  *
374
413
  * Returns the rewritten text, or undefined when nothing was rewritten, so a
375
414
  * text with no such name stays byte-identical.
376
415
  */
377
- function qualifyNamesFromSource(text, program, sourceFileRel, args) {
416
+ function qualifyNamesFromSource(text, program, sourceFileRel, printed, args) {
378
417
  if (!sourceFileRel)
379
418
  return undefined;
380
419
  const source = program.getSourceFile(path.join(args.repoRoot, sourceFileRel));
@@ -391,6 +430,14 @@ function qualifyNamesFromSource(text, program, sourceFileRel, args) {
391
430
  ts.forEachChild(node, collect);
392
431
  };
393
432
  collect(parsed);
433
+ const specOf = (file) => {
434
+ const rel = path.relative(args.repoRoot, file.fileName);
435
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
436
+ return undefined;
437
+ if (rel.split(path.sep).includes('node_modules'))
438
+ return undefined;
439
+ return entryRelativeSpecifier(args.entryDir, args.repoRoot, rel.split(path.sep).join('/'));
440
+ };
394
441
  const imports = new Map();
395
442
  const importFor = (name) => {
396
443
  if (imports.has(name))
@@ -399,12 +446,8 @@ function qualifyNamesFromSource(text, program, sourceFileRel, args) {
399
446
  const atSource = checker.resolveName(name, source, meaning, false);
400
447
  const target = atSource && resolveSymbolAliases(checker, atSource);
401
448
  const declaringFile = target?.declarations?.[0]?.getSourceFile();
402
- const rel = declaringFile && path.relative(args.repoRoot, declaringFile.fileName);
403
- if (target &&
404
- declaringFile &&
405
- rel &&
406
- !rel.startsWith('..') &&
407
- !rel.split(path.sep).includes('node_modules')) {
449
+ const spec = declaringFile && specOf(declaringFile);
450
+ if (target && declaringFile && spec) {
408
451
  const moduleSymbol = checker.getSymbolAtLocation(declaringFile);
409
452
  const exported = moduleSymbol
410
453
  ? checker
@@ -412,28 +455,46 @@ function qualifyNamesFromSource(text, program, sourceFileRel, args) {
412
455
  .filter((candidate) => resolveSymbolAliases(checker, candidate) === target)
413
456
  : [];
414
457
  const chosen = exported.find((candidate) => candidate.getName() === name) ?? exported[0];
415
- if (chosen) {
416
- found = {
417
- spec: entryRelativeSpecifier(args.entryDir, args.repoRoot, rel.split(path.sep).join('/')),
418
- exportName: chosen.getName(),
419
- };
420
- }
458
+ if (chosen)
459
+ found = { spec, exportPath: [chosen.getName()] };
421
460
  }
461
+ if (!found && atSource)
462
+ found = packageImportOf(program, atSource, args.resolveFromEntry);
463
+ if (!found && !atSource)
464
+ found = printedImportOf(program, name, printed, specOf);
422
465
  imports.set(name, found);
423
466
  return found;
424
467
  };
425
468
  const leftmost = (name) => ts.isIdentifier(name) ? name : leftmost(name.left);
426
- const renameLeftmost = (name, to) => ts.isIdentifier(name)
427
- ? ts.factory.createIdentifier(to)
428
- : ts.factory.createQualifiedName(renameLeftmost(name.left, to), name.right);
469
+ const entityName = (parts) => parts
470
+ .slice(1)
471
+ .reduce((left, part) => ts.factory.createQualifiedName(left, part), ts.factory.createIdentifier(parts[0]));
472
+ const replaceLeftmost = (name, to) => ts.isIdentifier(name)
473
+ ? entityName(to)
474
+ : ts.factory.createQualifiedName(replaceLeftmost(name.left, to), name.right);
475
+ const dropLeftmost = (name) => ts.isIdentifier(name.left)
476
+ ? name.right
477
+ : ts.factory.createQualifiedName(dropLeftmost(name.left), name.right);
478
+ // What follows `import('<spec>')`: the export path, then the reference's
479
+ // own qualifier. Through a namespace import the namespace's name goes, and
480
+ // the next name must be something the package exports.
481
+ const qualifierFor = (typeName, target) => {
482
+ if ('exportPath' in target)
483
+ return replaceLeftmost(typeName, target.exportPath);
484
+ if (ts.isIdentifier(typeName))
485
+ return undefined;
486
+ const qualifier = dropLeftmost(typeName);
487
+ return target.namespaceExports.has(leftmost(qualifier).text) ? qualifier : undefined;
488
+ };
429
489
  let rewrites = 0;
430
490
  const rewrite = (node) => {
431
491
  if (ts.isTypeReferenceNode(node)) {
432
492
  const name = leftmost(node.typeName).text;
433
493
  const target = typeParameters.has(name) ? undefined : importFor(name);
434
- if (target) {
494
+ const qualifier = target && qualifierFor(node.typeName, target);
495
+ if (target && qualifier) {
435
496
  rewrites += 1;
436
- return ts.factory.createImportTypeNode(ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(target.spec)), undefined, renameLeftmost(node.typeName, target.exportName), node.typeArguments?.map((argument) => rewrite(argument)), false);
497
+ return ts.factory.createImportTypeNode(ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(target.spec)), undefined, qualifier, node.typeArguments?.map((argument) => rewrite(argument)), false);
437
498
  }
438
499
  }
439
500
  return ts.visitEachChild(node, rewrite, /* context */ undefined);
@@ -445,6 +506,96 @@ function qualifyNamesFromSource(text, program, sourceFileRel, args) {
445
506
  .createPrinter({ removeComments: true })
446
507
  .printNode(ts.EmitHint.Unspecified, rewritten, parsed.getSourceFile());
447
508
  }
509
+ /**
510
+ * carrick#1789: how the surface entry imports `local`, a name the source file
511
+ * binds with an import from an installed package, or undefined.
512
+ *
513
+ * The entry names the module the source's import resolves to by its file
514
+ * path, as the node builder names a library type it cannot reach by a bare
515
+ * specifier; the post-emit rewrite turns that path into the package's bare
516
+ * specifier and pins the installed version (`installedPackageSpecifier`). A
517
+ * bare specifier in the entry itself would be resolved from the entry: on a
518
+ * Deno service that goes through the graph's virtual `node_modules`, and the
519
+ * emitter then reads the package there, cannot name its types from the
520
+ * service's own modules (TS2742), and skips their declarations.
521
+ *
522
+ * Kept only when the module lies inside an installed package and the bare
523
+ * specifier that package gives resolves from the entry to the same file. So
524
+ * a tsconfig path alias to the repo's own module, a workspace package linked
525
+ * from the repo (no installed copy to pin), and an import of another
526
+ * installed copy than the entry reaches all stay as written.
527
+ */
528
+ function packageImportOf(program, local, resolveFromEntry) {
529
+ if (!resolveFromEntry || !(local.flags & ts.SymbolFlags.Alias))
530
+ return undefined;
531
+ const declaration = local.declarations?.[0];
532
+ let statement;
533
+ let exportName;
534
+ if (declaration && ts.isImportSpecifier(declaration)) {
535
+ // `import { "a-b" as name }` has no qualifier an import type can write.
536
+ const exported = declaration.propertyName ?? declaration.name;
537
+ if (!ts.isIdentifier(exported))
538
+ return undefined;
539
+ statement = declaration.parent.parent.parent;
540
+ exportName = exported.text;
541
+ }
542
+ else if (declaration && ts.isImportClause(declaration)) {
543
+ statement = declaration.parent;
544
+ exportName = 'default';
545
+ }
546
+ else if (declaration && ts.isNamespaceImport(declaration)) {
547
+ statement = declaration.parent.parent;
548
+ }
549
+ else {
550
+ return undefined;
551
+ }
552
+ if (!ts.isImportDeclaration(statement))
553
+ return undefined;
554
+ const checker = program.getTypeChecker();
555
+ const moduleSymbol = checker.getSymbolAtLocation(statement.moduleSpecifier);
556
+ const moduleFile = moduleSymbol?.declarations?.find(ts.isSourceFile);
557
+ if (!moduleSymbol || !moduleFile)
558
+ return undefined;
559
+ const installed = installedPackageSpecifier(moduleFile.fileName, exportName);
560
+ const fromEntry = installed && resolveFromEntry(installed.specifier);
561
+ if (!fromEntry || realPath(fromEntry) !== realPath(moduleFile.fileName))
562
+ return undefined;
563
+ const spec = moduleFile.fileName;
564
+ const exported = new Set(checker.getExportsOfModule(moduleSymbol).map((symbol) => symbol.getName()));
565
+ if (exportName === undefined)
566
+ return { spec, namespaceExports: exported };
567
+ return exported.has(exportName) ? { spec, exportPath: [exportName] } : undefined;
568
+ }
569
+ /**
570
+ * carrick#1836: the import for `name` from the one declaration the inference
571
+ * recorded printing it for, or undefined.
572
+ *
573
+ * Undefined when the name was recorded for no declaration or for several,
574
+ * when the recorded module is not in this program or not inside the repo
575
+ * (`specOf`), and when its export path does not lead to a type there: a record
576
+ * that does not check out names nothing, and the text stays as written.
577
+ */
578
+ function printedImportOf(program, name, printed, specOf) {
579
+ const recorded = (printed ?? []).filter((entry) => entry.name === name);
580
+ const identities = new Set(recorded.map((entry) => `${entry.file}\0${entry.export_path.join('.')}`));
581
+ if (identities.size !== 1)
582
+ return undefined;
583
+ const entry = recorded[0];
584
+ const declaring = program.getSourceFile(entry.file);
585
+ const spec = declaring && specOf(declaring);
586
+ if (!declaring || !spec)
587
+ return undefined;
588
+ const checker = program.getTypeChecker();
589
+ let current = checker.getSymbolAtLocation(declaring);
590
+ for (const part of entry.export_path) {
591
+ const next = current && checker.getExportsOfModule(current).find((s) => s.getName() === part);
592
+ current = next && resolveSymbolAliases(checker, next);
593
+ }
594
+ if (!current || !(current.flags & (ts.SymbolFlags.Type | ts.SymbolFlags.Namespace))) {
595
+ return undefined;
596
+ }
597
+ return { spec, exportPath: entry.export_path };
598
+ }
448
599
  /** The type node of `type __LiteralAnchor = <text>;`, or undefined. */
449
600
  function parseLiteralAnchor(text) {
450
601
  const parsed = ts.createSourceFile('literal-anchor.ts', `type __LiteralAnchor = ${text};`, ts.ScriptTarget.Latest, true);
@@ -914,21 +1065,62 @@ function locatorFailureReason(request) {
914
1065
  * else the first expression starting on line_number.
915
1066
  */
916
1067
  export function locateNode(sourceFile, request) {
1068
+ return locate(sourceFile, request)?.node;
1069
+ }
1070
+ /** The node `locateNode` resolves, and which of its three locators found it. */
1071
+ function locate(sourceFile, request) {
917
1072
  if (request.span_start !== undefined && request.span_end !== undefined) {
918
1073
  const bySpan = tightestCoveringNode(sourceFile, request.span_start, request.span_end);
919
1074
  if (bySpan)
920
- return bySpan;
1075
+ return { node: bySpan, by: 'span' };
921
1076
  }
922
1077
  if (request.expression_text) {
923
1078
  const byText = nodeByExpressionText(sourceFile, request.expression_text, request.line_number);
924
1079
  if (byText)
925
- return byText;
1080
+ return { node: byText, by: 'text' };
926
1081
  }
927
1082
  if (request.line_number !== undefined) {
928
- return firstExpressionOnLine(sourceFile, request.line_number);
1083
+ const byLine = firstExpressionOnLine(sourceFile, request.line_number);
1084
+ if (byLine)
1085
+ return { node: byLine, by: 'line' };
929
1086
  }
930
1087
  return undefined;
931
1088
  }
1089
+ /**
1090
+ * The declaration `node` is the name of, when it names one (carrick#1785): a
1091
+ * type alias, interface, class, function, method, property, accessor, enum or
1092
+ * enum member, namespace, or an import or export binding (whose `propertyName`
1093
+ * names the binding too: `export { a as b }`).
1094
+ *
1095
+ * Not a binding whose name is also the value read at that position: a
1096
+ * destructured element and a shorthand property keep resolving. A variable,
1097
+ * a parameter and a property assignment never reach here, because each is a
1098
+ * preferred target the line walk takes before its name.
1099
+ */
1100
+ function declarationNamedBy(node) {
1101
+ const parent = node.parent;
1102
+ if (!parent)
1103
+ return undefined;
1104
+ // A specifier's only children are its names.
1105
+ if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent))
1106
+ return parent;
1107
+ const named = ts.isTypeAliasDeclaration(parent) ||
1108
+ ts.isInterfaceDeclaration(parent) ||
1109
+ ts.isClassDeclaration(parent) ||
1110
+ ts.isFunctionDeclaration(parent) ||
1111
+ ts.isMethodDeclaration(parent) ||
1112
+ ts.isMethodSignature(parent) ||
1113
+ ts.isPropertyDeclaration(parent) ||
1114
+ ts.isPropertySignature(parent) ||
1115
+ ts.isGetAccessorDeclaration(parent) ||
1116
+ ts.isSetAccessorDeclaration(parent) ||
1117
+ ts.isEnumDeclaration(parent) ||
1118
+ ts.isEnumMember(parent) ||
1119
+ ts.isModuleDeclaration(parent) ||
1120
+ ts.isImportClause(parent) ||
1121
+ ts.isNamespaceImport(parent);
1122
+ return named && parent.name === node ? parent : undefined;
1123
+ }
932
1124
  function isPreferredTarget(node) {
933
1125
  return (ts.isExpression(node) ||
934
1126
  ts.isVariableDeclaration(node) ||
@@ -71,6 +71,36 @@ export interface LiteralAnchorRequest {
71
71
  * record's `source_file` stays `<inline>`: the answer is still the text.
72
72
  */
73
73
  source_file?: string;
74
+ /**
75
+ * carrick#1836: what the text's bare names meant where they were printed,
76
+ * for the names `source_file` cannot resolve. A name listed once is
77
+ * imported from the module that declares it; a name listed for two
78
+ * declarations is left as written.
79
+ */
80
+ printed_names?: PrintedName[];
81
+ /**
82
+ * carrick#1842: the text is a body the call site reads as raw text. Copied
83
+ * onto the alias's record, where the check phase reads it.
84
+ */
85
+ raw_text_read?: true;
86
+ }
87
+ /**
88
+ * The declaration a bare name in printed type text meant (carrick#1836).
89
+ *
90
+ * The v1 inferrer prints some types with no enclosing declaration, and that
91
+ * print writes every named type by its bare name, whether or not the file the
92
+ * request names can see it. The compiler knew the symbol when it printed the
93
+ * name; this is that symbol, recorded as the module that declares it and the
94
+ * export path that reaches it there (`['Status']`, or `['Billing', 'Kind']`
95
+ * for a namespace member printed as `Kind`).
96
+ */
97
+ export interface PrintedName {
98
+ /** The name as the text prints it: the leftmost part of a reference. */
99
+ name: string;
100
+ /** Absolute path of the module that declares it. */
101
+ file: string;
102
+ /** Export names from that module down to the declaration. */
103
+ export_path: string[];
74
104
  }
75
105
  /**
76
106
  * Addressable handler: `export type A = Awaited<ReturnType<typeof
@@ -152,7 +182,10 @@ export type SelfCheckOutcome = 'ok' | 'allowlisted_external' | 'decayed_internal
152
182
  * contract.
153
183
  * - `machinery_envelope`: the return resolved to transport (a
154
184
  * Response/Request-shaped envelope) and no payload was recoverable inside
155
- * it or from the handler's returned arguments.
185
+ * it or from the handler's returned arguments. On a consumer call result it
186
+ * is a decided abstain: what the call's result carrier holds is transport
187
+ * the service's wrapper rules verify and read no payload out of, such as a
188
+ * request library's own response object (carrick#1841).
156
189
  * - `coerced_input`: a request schema's INPUT is `any`/`unknown` at this
157
190
  * position while its parsed output is concrete, which is what a coercion
158
191
  * declares (carrick#1101). The published type carries the output there, so
@@ -251,6 +284,34 @@ export interface CaptureAliasRecord {
251
284
  * absent when there are none.
252
285
  */
253
286
  undeclared_names?: string[];
287
+ /**
288
+ * Every position at which this alias's type, as the emitted tree states it,
289
+ * holds TypeScript's unresolved-reference placeholder (carrick#1446): `''`
290
+ * when the alias's own type does not resolve, member paths otherwise, in the
291
+ * deep walk's notation, sorted by path. The compiler prints the placeholder
292
+ * as the name it could not follow, so the text reads like a type while every
293
+ * reader of the tree reads `any` there.
294
+ *
295
+ * Read on the self-check program, where the producer's installed packages
296
+ * resolve, so a name only a missing install leaves unresolved is listed only
297
+ * on a bare checkout. A literal anchor demoted because its text names a
298
+ * module the emit skipped is listed at its root: that text is what the
299
+ * index serves for it, and it names a module the tree does not hold.
300
+ *
301
+ * Kept apart from `any_provenance` on purpose: the check phase pre-gates
302
+ * on `any_provenance[0]`, and this list is what the index tells a reader,
303
+ * not a verdict. The scanner joins it onto the manifest entry beside the
304
+ * entry's other provenance. Absent when every position resolves.
305
+ */
306
+ unresolved_in_tree?: TypeProvenance[];
307
+ /**
308
+ * carrick#1842: the alias is a body its call site reads as raw text, from
309
+ * the literal anchor that published it. The check phase reads a pair with
310
+ * this on either side unverifiable: raw text states no structural contract,
311
+ * whether or not the other side's type assigns to `string`. Absent
312
+ * otherwise, and absent on every record a release before this one stored.
313
+ */
314
+ raw_text_read?: true;
254
315
  }
255
316
  /** Aggregate fidelity metric, emitted per capture (one service). */
256
317
  export interface CaptureFidelity {
@@ -295,7 +356,8 @@ export interface CaptureStubOptions {
295
356
  /**
296
357
  * The scanned repo's root, the upper bound of the search for a tsconfig
297
358
  * above `repoRoot` when none is named (carrick#1776). Without it only
298
- * `repoRoot` is searched.
359
+ * `repoRoot` is searched. It is a protected tree as well: `outDir` may
360
+ * lie inside it only beneath a `.carrick` directory (carrick#1768).
299
361
  */
300
362
  scanRoot?: string;
301
363
  }
@@ -431,8 +493,6 @@ export interface CheckResult {
431
493
  * missing (soundness over availability — pinned design, Check step 2). */
432
494
  isolation: 'pnpm' | 'unavailable';
433
495
  install_ok: boolean;
434
- /** Scrubbed install-failure summary when install_ok is false. */
435
- install_error?: string;
436
496
  ts_version: string;
437
497
  /** Verdicts, sorted by pair_id for byte-stable output. */
438
498
  verdicts: CheckVerdict[];
@@ -12,6 +12,8 @@
12
12
  * IsUnknown/IsNever gate fired (TS2344) -> unverifiable
13
13
  * IsVoid gate fired (TS2344) -> unverifiable (no body read)
14
14
  * IsFormBody gate fired (TS2344) -> unverifiable (form-encoded body)
15
+ * IsByteBody gate fired (TS2344) -> unverifiable (bytes, even agreeing)
16
+ * raw-text read marked on a side -> unverifiable (text, even agreeing)
15
17
  * assignment-class error -> incompatible
16
18
  * no diagnostics -> compatible [lowest precedence]
17
19
  *
@@ -22,7 +24,7 @@
22
24
  * Seam: node builtins + this bundle only.
23
25
  */
24
26
  import type { CheckVerdict } from './api.js';
25
- import { type ProbePlan } from './check-probe.js';
27
+ import { type ProbePlan, type Side } from './check-probe.js';
26
28
  import { type ScrubContext } from './check-scrub.js';
27
29
  import type { PairDeepFindings } from './check-deep.js';
28
30
  import { type PairFieldReport } from './check-fields.js';
@@ -64,6 +66,13 @@ export interface ClassifyInput {
64
66
  * run, in which case the tsc text stands alone.
65
67
  */
66
68
  fieldReport?: PairFieldReport;
69
+ /**
70
+ * The side whose capture record says it reads the body as raw text
71
+ * (carrick#1842), the sent side when both do. Read from the record, not the
72
+ * type: a text read publishes `string`, which no type gate can tell from a
73
+ * JSON body that is a string.
74
+ */
75
+ rawTextSide?: Side;
67
76
  }
68
77
  /** Classify one pair into exactly one bucket, honouring the precedence order. */
69
78
  export declare function classifyPair(input: ClassifyInput): CheckVerdict;
@@ -12,6 +12,8 @@
12
12
  * IsUnknown/IsNever gate fired (TS2344) -> unverifiable
13
13
  * IsVoid gate fired (TS2344) -> unverifiable (no body read)
14
14
  * IsFormBody gate fired (TS2344) -> unverifiable (form-encoded body)
15
+ * IsByteBody gate fired (TS2344) -> unverifiable (bytes, even agreeing)
16
+ * raw-text read marked on a side -> unverifiable (text, even agreeing)
15
17
  * assignment-class error -> incompatible
16
18
  * no diagnostics -> compatible [lowest precedence]
17
19
  *
@@ -172,19 +174,18 @@ export function classifyPair(input) {
172
174
  };
173
175
  }
174
176
  // 4c. A body of bytes (carrick#1793): a blob, a buffer or a stream has no JSON
175
- // shape, so a MISMATCH between the containers two sides hold the bytes
176
- // in (a `Uint8Array` sent, read with `.blob()`) is not a drift. It
177
- // overrides a mismatch only: bytes that assign to bytes (a stream sent
178
- // where a stream is read) agree, and that verdict stands. When both
179
- // sides are bytes the sent side is named, whatever order the
180
- // diagnostics came in.
177
+ // shape, and the check cannot read a file's content. So a pair with
178
+ // bytes on either side states no contract, whether the two sides
179
+ // mismatch (a `Uint8Array` sent, read with `.blob()`) or agree (a stream
180
+ // sent where a stream is read, carrick#1812). When both sides are bytes
181
+ // the sent side is named, whatever order the diagnostics came in.
181
182
  const firedGates = new Set(gateDiags.map((d) => plan.gateLines.get(d.line)));
182
183
  const bytesGate = firedGates.has('sent:bytes')
183
184
  ? 'sent:bytes'
184
185
  : firedGates.has('expected:bytes')
185
186
  ? 'expected:bytes'
186
187
  : undefined;
187
- const bytesVerdict = () => {
188
+ if (bytesGate) {
188
189
  const { side } = sideForGate(bytesGate, plan);
189
190
  return {
190
191
  ...base,
@@ -193,7 +194,21 @@ export function classifyPair(input) {
193
194
  diagnostic: `the ${side} body is bytes (a blob, a buffer or a stream), which has no JSON shape to compare with the other side.`,
194
195
  ...notAFact(`the ${side} body is bytes`, side),
195
196
  };
196
- };
197
+ }
198
+ // 4d. A body read as raw text (carrick#1842): a `.text()` read, or a request
199
+ // library's text format, takes the body unparsed, so `string` there
200
+ // states no structural contract. Like bytes, the pair is not compared,
201
+ // whether or not the other side's type assigns to `string`.
202
+ if (input.rawTextSide && plan.spec.protocol === 'http') {
203
+ const side = input.rawTextSide;
204
+ return {
205
+ ...base,
206
+ bucket: 'unverifiable',
207
+ gate: `${side}:text`,
208
+ diagnostic: `the ${side} reads the body as raw text, which has no JSON shape to compare with the other side.`,
209
+ ...notAFact(`the ${side} reads the body as raw text`, side),
210
+ };
211
+ }
197
212
  // 5. Assignment-class error on the DECISIVE assignment line -> incompatible.
198
213
  //
199
214
  // On an `http` pair that line is the JSON wire assignment, not the declared
@@ -206,8 +221,6 @@ export function classifyPair(input) {
206
221
  const decisiveLine = decisiveAssignmentLine(plan);
207
222
  const assignDiag = probeDiags.find((d) => d.line === decisiveLine && ASSIGNMENT_CODES.has(d.code));
208
223
  if (assignDiag) {
209
- if (bytesGate)
210
- return bytesVerdict();
211
224
  const text = scrubDiagnostic(assignDiag.message, scrubCtx, plan.sentEndpoint.alias, plan.expectedEndpoint.alias);
212
225
  return {
213
226
  ...base,
@@ -243,8 +256,6 @@ export function classifyPair(input) {
243
256
  if (wireOther) {
244
257
  const declaredMismatch = probeDiags.find((d) => d.line === plan.assignmentLine && ASSIGNMENT_CODES.has(d.code));
245
258
  if (declaredMismatch) {
246
- if (bytesGate)
247
- return bytesVerdict();
248
259
  return {
249
260
  ...base,
250
261
  bucket: 'incompatible',