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.
- package/package.json +6 -6
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/sidecar/dist/src/capture/anchors.d.ts +13 -0
- package/sidecar/dist/src/capture/anchors.js +229 -37
- package/sidecar/dist/src/capture/api.d.ts +64 -4
- package/sidecar/dist/src/capture/check-classify.d.ts +10 -1
- package/sidecar/dist/src/capture/check-classify.js +23 -12
- package/sidecar/dist/src/capture/check-fields.js +4 -6
- package/sidecar/dist/src/capture/check-probe.js +16 -6
- package/sidecar/dist/src/capture/check-scrub.d.ts +3 -0
- package/sidecar/dist/src/capture/check-scrub.js +8 -4
- package/sidecar/dist/src/capture/check.js +75 -49
- package/sidecar/dist/src/capture/deep-walk.d.ts +20 -0
- package/sidecar/dist/src/capture/deep-walk.js +51 -11
- package/sidecar/dist/src/capture/guarded-fs.d.ts +5 -1
- package/sidecar/dist/src/capture/guarded-fs.js +23 -1
- package/sidecar/dist/src/capture/index.js +14 -2
- package/sidecar/dist/src/capture/member-name.d.ts +20 -0
- package/sidecar/dist/src/capture/member-name.js +24 -0
- package/sidecar/dist/src/capture/self-check.js +50 -9
- 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 +67 -0
- package/sidecar/dist/src/failure-path.js +236 -0
- 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 +60 -99
- package/sidecar/dist/src/type-inferrer.d.ts +35 -3
- package/sidecar/dist/src/type-inferrer.js +208 -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"
|
|
@@ -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
|
-
|
|
189
|
-
if (!
|
|
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
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
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
|
|
264
|
-
//
|
|
265
|
-
//
|
|
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.
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
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
|
|
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
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// where a stream is read
|
|
179
|
-
//
|
|
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
|
-
|
|
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',
|