carrick 0.3.105 → 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.
- package/package.json +6 -6
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/sidecar/dist/src/capture/anchors.d.ts +7 -0
- package/sidecar/dist/src/capture/anchors.js +151 -24
- package/sidecar/dist/src/capture/api.d.ts +42 -1
- package/sidecar/dist/src/capture/check-classify.d.ts +9 -1
- package/sidecar/dist/src/capture/check-classify.js +15 -0
- package/sidecar/dist/src/capture/check.js +13 -1
- package/sidecar/dist/src/capture/index.js +8 -0
- package/sidecar/dist/src/capture/self-check.js +9 -0
- package/sidecar/dist/src/capture/service-config.d.ts +2 -0
- package/sidecar/dist/src/capture/service-config.js +1 -1
- package/sidecar/dist/src/failure-path.d.ts +34 -3
- package/sidecar/dist/src/failure-path.js +59 -35
- package/sidecar/dist/src/printed-names.d.ts +43 -0
- package/sidecar/dist/src/printed-names.js +186 -0
- package/sidecar/dist/src/retype.js +57 -109
- package/sidecar/dist/src/type-inferrer.d.ts +33 -3
- package/sidecar/dist/src/type-inferrer.js +191 -13
- package/sidecar/dist/src/type-structural-expander.js +10 -1
- package/sidecar/dist/src/types.d.ts +24 -0
- package/sidecar/dist/src/validators.d.ts +76 -0
- 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.
|
|
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.
|
|
62
|
-
"@carrick-tools/cli-darwin-x64": "0.3.
|
|
63
|
-
"@carrick-tools/cli-linux-arm64": "0.3.
|
|
64
|
-
"@carrick-tools/cli-linux-x64": "0.3.
|
|
65
|
-
"@carrick-tools/cli-win32-x64": "0.3.
|
|
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.
|
|
4
|
+
"version": "0.3.106",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Carrick",
|
|
7
7
|
"email": "hello@carrick.tools"
|
|
@@ -73,6 +73,13 @@ export declare function resolveAnchor(program: ts.Program, request: CaptureAncho
|
|
|
73
73
|
* these resolves through that module instead of dangling in the entry.
|
|
74
74
|
*/
|
|
75
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;
|
|
76
83
|
}): ResolvedAnchor;
|
|
77
84
|
/**
|
|
78
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
|
|
@@ -390,15 +395,25 @@ function substituteUndeclaredNamesInText(text, program, destination) {
|
|
|
390
395
|
* member that reads `any`) or a global of the same name (`Notification`).
|
|
391
396
|
* Each reference whose name the source resolves to a type that a module
|
|
392
397
|
* inside the repo exports becomes `import('<module>').<export>`, with its
|
|
393
|
-
* qualifier and type arguments kept.
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
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.
|
|
397
412
|
*
|
|
398
413
|
* Returns the rewritten text, or undefined when nothing was rewritten, so a
|
|
399
414
|
* text with no such name stays byte-identical.
|
|
400
415
|
*/
|
|
401
|
-
function qualifyNamesFromSource(text, program, sourceFileRel, args) {
|
|
416
|
+
function qualifyNamesFromSource(text, program, sourceFileRel, printed, args) {
|
|
402
417
|
if (!sourceFileRel)
|
|
403
418
|
return undefined;
|
|
404
419
|
const source = program.getSourceFile(path.join(args.repoRoot, sourceFileRel));
|
|
@@ -415,6 +430,14 @@ function qualifyNamesFromSource(text, program, sourceFileRel, args) {
|
|
|
415
430
|
ts.forEachChild(node, collect);
|
|
416
431
|
};
|
|
417
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
|
+
};
|
|
418
441
|
const imports = new Map();
|
|
419
442
|
const importFor = (name) => {
|
|
420
443
|
if (imports.has(name))
|
|
@@ -423,12 +446,8 @@ function qualifyNamesFromSource(text, program, sourceFileRel, args) {
|
|
|
423
446
|
const atSource = checker.resolveName(name, source, meaning, false);
|
|
424
447
|
const target = atSource && resolveSymbolAliases(checker, atSource);
|
|
425
448
|
const declaringFile = target?.declarations?.[0]?.getSourceFile();
|
|
426
|
-
const
|
|
427
|
-
if (target &&
|
|
428
|
-
declaringFile &&
|
|
429
|
-
rel &&
|
|
430
|
-
!rel.startsWith('..') &&
|
|
431
|
-
!rel.split(path.sep).includes('node_modules')) {
|
|
449
|
+
const spec = declaringFile && specOf(declaringFile);
|
|
450
|
+
if (target && declaringFile && spec) {
|
|
432
451
|
const moduleSymbol = checker.getSymbolAtLocation(declaringFile);
|
|
433
452
|
const exported = moduleSymbol
|
|
434
453
|
? checker
|
|
@@ -436,28 +455,46 @@ function qualifyNamesFromSource(text, program, sourceFileRel, args) {
|
|
|
436
455
|
.filter((candidate) => resolveSymbolAliases(checker, candidate) === target)
|
|
437
456
|
: [];
|
|
438
457
|
const chosen = exported.find((candidate) => candidate.getName() === name) ?? exported[0];
|
|
439
|
-
if (chosen)
|
|
440
|
-
found = {
|
|
441
|
-
spec: entryRelativeSpecifier(args.entryDir, args.repoRoot, rel.split(path.sep).join('/')),
|
|
442
|
-
exportName: chosen.getName(),
|
|
443
|
-
};
|
|
444
|
-
}
|
|
458
|
+
if (chosen)
|
|
459
|
+
found = { spec, exportPath: [chosen.getName()] };
|
|
445
460
|
}
|
|
461
|
+
if (!found && atSource)
|
|
462
|
+
found = packageImportOf(program, atSource, args.resolveFromEntry);
|
|
463
|
+
if (!found && !atSource)
|
|
464
|
+
found = printedImportOf(program, name, printed, specOf);
|
|
446
465
|
imports.set(name, found);
|
|
447
466
|
return found;
|
|
448
467
|
};
|
|
449
468
|
const leftmost = (name) => ts.isIdentifier(name) ? name : leftmost(name.left);
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
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
|
+
};
|
|
453
489
|
let rewrites = 0;
|
|
454
490
|
const rewrite = (node) => {
|
|
455
491
|
if (ts.isTypeReferenceNode(node)) {
|
|
456
492
|
const name = leftmost(node.typeName).text;
|
|
457
493
|
const target = typeParameters.has(name) ? undefined : importFor(name);
|
|
458
|
-
|
|
494
|
+
const qualifier = target && qualifierFor(node.typeName, target);
|
|
495
|
+
if (target && qualifier) {
|
|
459
496
|
rewrites += 1;
|
|
460
|
-
return ts.factory.createImportTypeNode(ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(target.spec)), undefined,
|
|
497
|
+
return ts.factory.createImportTypeNode(ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(target.spec)), undefined, qualifier, node.typeArguments?.map((argument) => rewrite(argument)), false);
|
|
461
498
|
}
|
|
462
499
|
}
|
|
463
500
|
return ts.visitEachChild(node, rewrite, /* context */ undefined);
|
|
@@ -469,6 +506,96 @@ function qualifyNamesFromSource(text, program, sourceFileRel, args) {
|
|
|
469
506
|
.createPrinter({ removeComments: true })
|
|
470
507
|
.printNode(ts.EmitHint.Unspecified, rewritten, parsed.getSourceFile());
|
|
471
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
|
+
}
|
|
472
599
|
/** The type node of `type __LiteralAnchor = <text>;`, or undefined. */
|
|
473
600
|
function parseLiteralAnchor(text) {
|
|
474
601
|
const parsed = ts.createSourceFile('literal-anchor.ts', `type __LiteralAnchor = ${text};`, ts.ScriptTarget.Latest, true);
|
|
@@ -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
|
|
@@ -271,6 +304,14 @@ export interface CaptureAliasRecord {
|
|
|
271
304
|
* entry's other provenance. Absent when every position resolves.
|
|
272
305
|
*/
|
|
273
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;
|
|
274
315
|
}
|
|
275
316
|
/** Aggregate fidelity metric, emitted per capture (one service). */
|
|
276
317
|
export interface CaptureFidelity {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* IsVoid gate fired (TS2344) -> unverifiable (no body read)
|
|
14
14
|
* IsFormBody gate fired (TS2344) -> unverifiable (form-encoded body)
|
|
15
15
|
* IsByteBody gate fired (TS2344) -> unverifiable (bytes, even agreeing)
|
|
16
|
+
* raw-text read marked on a side -> unverifiable (text, even agreeing)
|
|
16
17
|
* assignment-class error -> incompatible
|
|
17
18
|
* no diagnostics -> compatible [lowest precedence]
|
|
18
19
|
*
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
* Seam: node builtins + this bundle only.
|
|
24
25
|
*/
|
|
25
26
|
import type { CheckVerdict } from './api.js';
|
|
26
|
-
import { type ProbePlan } from './check-probe.js';
|
|
27
|
+
import { type ProbePlan, type Side } from './check-probe.js';
|
|
27
28
|
import { type ScrubContext } from './check-scrub.js';
|
|
28
29
|
import type { PairDeepFindings } from './check-deep.js';
|
|
29
30
|
import { type PairFieldReport } from './check-fields.js';
|
|
@@ -65,6 +66,13 @@ export interface ClassifyInput {
|
|
|
65
66
|
* run, in which case the tsc text stands alone.
|
|
66
67
|
*/
|
|
67
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;
|
|
68
76
|
}
|
|
69
77
|
/** Classify one pair into exactly one bucket, honouring the precedence order. */
|
|
70
78
|
export declare function classifyPair(input: ClassifyInput): CheckVerdict;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* IsVoid gate fired (TS2344) -> unverifiable (no body read)
|
|
14
14
|
* IsFormBody gate fired (TS2344) -> unverifiable (form-encoded body)
|
|
15
15
|
* IsByteBody gate fired (TS2344) -> unverifiable (bytes, even agreeing)
|
|
16
|
+
* raw-text read marked on a side -> unverifiable (text, even agreeing)
|
|
16
17
|
* assignment-class error -> incompatible
|
|
17
18
|
* no diagnostics -> compatible [lowest precedence]
|
|
18
19
|
*
|
|
@@ -194,6 +195,20 @@ export function classifyPair(input) {
|
|
|
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
|
|
@@ -158,7 +158,8 @@ export async function runCheck(opts, onProgress) {
|
|
|
158
158
|
const spec = opts.pairs.find((p) => p.pair_key === v.pair_key);
|
|
159
159
|
v.pair_id = fnvOfSpec(spec);
|
|
160
160
|
}
|
|
161
|
-
const
|
|
161
|
+
const aliasRecords = readStubAliasRecords(opts.stubs);
|
|
162
|
+
const { preGated, probing } = preGate(plans, aliasRecords);
|
|
162
163
|
writeProbes(ws, probing);
|
|
163
164
|
const errors = [];
|
|
164
165
|
const degraded = [];
|
|
@@ -269,6 +270,7 @@ export async function runCheck(opts, onProgress) {
|
|
|
269
270
|
scrubCtx,
|
|
270
271
|
deepFindings: deepByPair.get(plan.pairId),
|
|
271
272
|
fieldReport: fieldsByPair.get(plan.pairId),
|
|
273
|
+
rawTextSide: rawTextSideOf(plan, aliasRecords),
|
|
272
274
|
})),
|
|
273
275
|
...preGated,
|
|
274
276
|
...unresolved,
|
|
@@ -355,6 +357,16 @@ function preGate(plans, aliasRecords) {
|
|
|
355
357
|
}
|
|
356
358
|
return { preGated, probing };
|
|
357
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* The side whose capture record marks its body as read raw (carrick#1842):
|
|
362
|
+
* the sent side when both are, as the bytes gate names it.
|
|
363
|
+
*/
|
|
364
|
+
function rawTextSideOf(plan, aliasRecords) {
|
|
365
|
+
return [plan.direction.sent, plan.direction.expected].find((side) => {
|
|
366
|
+
const endpoint = plan.spec[side];
|
|
367
|
+
return aliasRecords.get(endpoint.service_name)?.get(endpoint.alias)?.raw_text_read === true;
|
|
368
|
+
});
|
|
369
|
+
}
|
|
358
370
|
/** First side (producer, then consumer) whose capture recorded a deep decay. */
|
|
359
371
|
function deepDecayOf(plan, aliasRecords) {
|
|
360
372
|
for (const side of ['producer', 'consumer']) {
|
|
@@ -671,11 +671,19 @@ function resolveAnchors(opts, parsed, ctx, deno) {
|
|
|
671
671
|
siblingSymbolSpecs.set(anchor.symbol_name, entryRelativeSpecifier(ctx.entryDir, ctx.repoRoot, anchor.source_file));
|
|
672
672
|
}
|
|
673
673
|
}
|
|
674
|
+
// A module specifier as the entry resolves it, under the program's own
|
|
675
|
+
// resolution (the Deno graph's, for a Deno service).
|
|
676
|
+
const entryMode = entrySource?.impliedNodeFormat;
|
|
677
|
+
const resolveFromEntry = (specifier) => (deno
|
|
678
|
+
? deno.resolve(specifier, ctx.entryPath, options)
|
|
679
|
+
: ts.resolveModuleName(specifier, ctx.entryPath, options, ts.sys, undefined, undefined, entryMode)
|
|
680
|
+
.resolvedModule)?.resolvedFileName;
|
|
674
681
|
return opts.anchors.map((request) => resolveAnchor(program, request, {
|
|
675
682
|
repoRoot: ctx.repoRoot,
|
|
676
683
|
entryDir: ctx.entryDir,
|
|
677
684
|
placeholder: placeholders.get(request.alias),
|
|
678
685
|
siblingSymbolSpecs,
|
|
686
|
+
resolveFromEntry,
|
|
679
687
|
}));
|
|
680
688
|
}
|
|
681
689
|
finally {
|
|
@@ -247,6 +247,13 @@ function buildSurfaceSpanIndex(surfaceSource) {
|
|
|
247
247
|
return spans.find((span) => position >= span.start && position <= span.end)?.alias;
|
|
248
248
|
};
|
|
249
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* carrick#1842: a literal anchor whose text is a body read as raw text passes
|
|
252
|
+
* that mark to its record, where the check phase reads it.
|
|
253
|
+
*/
|
|
254
|
+
function rawTextMark(request) {
|
|
255
|
+
return request.kind === 'literal' && request.raw_text_read ? { raw_text_read: true } : {};
|
|
256
|
+
}
|
|
250
257
|
/** An alias that never reached a capture-native tier: the failure reason was
|
|
251
258
|
* recorded at demotion time; the surface line is `unknown` by construction. */
|
|
252
259
|
function demotedRecord(anchor) {
|
|
@@ -267,6 +274,7 @@ function demotedRecord(anchor) {
|
|
|
267
274
|
...(anchor.request.kind === 'literal' && anchor.namesUnemittedModule
|
|
268
275
|
? { unresolved_in_tree: [unemittedModuleProvenance()] }
|
|
269
276
|
: {}),
|
|
277
|
+
...rawTextMark(anchor.request),
|
|
270
278
|
};
|
|
271
279
|
}
|
|
272
280
|
function checkedRecord(anchor, ctx) {
|
|
@@ -448,6 +456,7 @@ function checkedRecord(anchor, ctx) {
|
|
|
448
456
|
.map((p) => unresolvedInTreeProvenance(p, [...unfoundNames].sort())),
|
|
449
457
|
}
|
|
450
458
|
: {}),
|
|
459
|
+
...rawTextMark(anchor.request),
|
|
451
460
|
};
|
|
452
461
|
}
|
|
453
462
|
/** Resolve a relative specifier from `fromAbs` to a tree file, if present. */
|
|
@@ -17,3 +17,5 @@
|
|
|
17
17
|
*/
|
|
18
18
|
/** Absolute path of the config that types the service, or undefined for none. */
|
|
19
19
|
export declare function findServiceTsconfig(serviceRoot: string, scanRoot?: string): string | undefined;
|
|
20
|
+
/** The path with every symlink resolved; the path as given when it does not exist. */
|
|
21
|
+
export declare function realPath(p: string): string;
|
|
@@ -47,7 +47,7 @@ export function findServiceTsconfig(serviceRoot, scanRoot) {
|
|
|
47
47
|
return undefined;
|
|
48
48
|
}
|
|
49
49
|
/** The path with every symlink resolved; the path as given when it does not exist. */
|
|
50
|
-
function realPath(p) {
|
|
50
|
+
export function realPath(p) {
|
|
51
51
|
try {
|
|
52
52
|
return fs.realpathSync(p);
|
|
53
53
|
}
|
|
@@ -20,17 +20,48 @@
|
|
|
20
20
|
* any other condition lets everything through. A node is on the failure path
|
|
21
21
|
* only when NO status in 200-299 can reach it.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* `res.status === 200` as the failure path so it can find a success read to
|
|
23
|
+
* The retype check (retype.ts) reads the same tests through `testsOnPath` and
|
|
24
|
+
* keeps its own policy on top: it also reads the false side of
|
|
25
|
+
* `res.status === 200` as the failure path, so it can find a success read to
|
|
26
26
|
* judge. Here a decided failure REMOVES a read, so the reading has to be sound
|
|
27
27
|
* the other way round: after `if (res.status === 204) return null`, 200 still
|
|
28
28
|
* gets through, and the json read that follows is the payload.
|
|
29
29
|
*/
|
|
30
30
|
import { Node } from 'ts-morph';
|
|
31
|
+
/** A set of HTTP statuses, as the predicate that admits them. */
|
|
32
|
+
export type Statuses = (status: number) => boolean;
|
|
33
|
+
/** The statuses `ok` is true for. */
|
|
34
|
+
export declare const SUCCEEDED: Statuses;
|
|
35
|
+
/** The statuses from 100 to 599 that `statuses` admits, in order. */
|
|
36
|
+
export declare function statusesIn(statuses: Statuses): number[];
|
|
37
|
+
/** One test of the response on the path to a node, as the side the node is on. */
|
|
38
|
+
export interface SideTaken {
|
|
39
|
+
/** Every status that can take this side, and maybe more (see `inexact`). */
|
|
40
|
+
admits: Statuses;
|
|
41
|
+
/**
|
|
42
|
+
* The test reads the response's `ok` or `status` in a form this reading
|
|
43
|
+
* cannot follow (`res.status === OK`, `codes.includes(res.status)`), or
|
|
44
|
+
* combines it with a condition that is not about the response
|
|
45
|
+
* (`res.ok && fresh`). `admits` then lets through more statuses than the
|
|
46
|
+
* side does: still sound for "no success status reaches the node", but no
|
|
47
|
+
* longer only the statuses the source singled out.
|
|
48
|
+
*/
|
|
49
|
+
inexact: boolean;
|
|
50
|
+
}
|
|
31
51
|
/**
|
|
32
52
|
* True when the source reaches `node` only after the response named by
|
|
33
53
|
* `isResponse` failed. The walk climbs from `node` to `boundary` (the function
|
|
34
54
|
* the call sits in) and never looks at a test outside it.
|
|
35
55
|
*/
|
|
36
56
|
export declare function reachedOnlyOnFailure(node: Node, boundary: Node, isResponse: (node: Node) => boolean): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Each test of the response on the path from `node` up to `boundary` (the
|
|
59
|
+
* whole file when it is `undefined`), as the side `node` is on: a branch of
|
|
60
|
+
* an `if` or a conditional expression it sits in, or an earlier `if` in an
|
|
61
|
+
* enclosing block one of whose branches cannot complete, which leaves the
|
|
62
|
+
* rest of the block to the other side. A test that does not read the
|
|
63
|
+
* response's `ok` or `status` is not listed.
|
|
64
|
+
*/
|
|
65
|
+
export declare function testsOnPath(node: Node, boundary: Node | undefined, isResponse: (node: Node) => boolean): SideTaken[];
|
|
66
|
+
/** `node` reads the response's `ok` or `status`, itself or anywhere inside it. */
|
|
67
|
+
export declare function readsResponseStatus(node: Node, isResponse: (node: Node) => boolean): boolean;
|