carrick 0.3.123 → 0.3.125
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/check-classify.js +11 -5
- package/sidecar/dist/src/capture/check.js +8 -1
- package/sidecar/dist/src/capture/deep-walk.d.ts +31 -0
- package/sidecar/dist/src/capture/deep-walk.js +43 -1
- package/sidecar/dist/src/capture/index.d.ts +1 -1
- package/sidecar/dist/src/capture/index.js +1 -1
- package/sidecar/dist/src/capture/machinery.js +6 -0
- package/sidecar/dist/src/capture/service-config.d.ts +11 -0
- package/sidecar/dist/src/capture/service-config.js +40 -0
- package/sidecar/dist/src/index.js +1 -1
- package/sidecar/dist/src/library-claims.d.ts +1 -1
- package/sidecar/dist/src/library-claims.js +170 -11
- package/sidecar/dist/src/position-rows.d.ts +44 -0
- package/sidecar/dist/src/position-rows.js +6 -4
- package/sidecar/dist/src/project-loader.d.ts +16 -0
- package/sidecar/dist/src/project-loader.js +51 -4
- package/sidecar/dist/src/type-inferrer.d.ts +56 -15
- package/sidecar/dist/src/type-inferrer.js +328 -67
- package/sidecar/dist/src/types.d.ts +32 -0
- package/sidecar/dist/src/validators.d.ts +392 -60
- package/sidecar/dist/src/validators.js +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "carrick",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.125",
|
|
4
4
|
"carrickCache": {
|
|
5
5
|
"cache_version": 22,
|
|
6
6
|
"intent_cache_version": 2
|
|
@@ -113,11 +113,11 @@
|
|
|
113
113
|
"zod": "^3.23.0"
|
|
114
114
|
},
|
|
115
115
|
"optionalDependencies": {
|
|
116
|
-
"@carrick-tools/cli-darwin-arm64": "0.3.
|
|
117
|
-
"@carrick-tools/cli-darwin-x64": "0.3.
|
|
118
|
-
"@carrick-tools/cli-linux-arm64": "0.3.
|
|
119
|
-
"@carrick-tools/cli-linux-x64": "0.3.
|
|
120
|
-
"@carrick-tools/cli-win32-x64": "0.3.
|
|
116
|
+
"@carrick-tools/cli-darwin-arm64": "0.3.125",
|
|
117
|
+
"@carrick-tools/cli-darwin-x64": "0.3.125",
|
|
118
|
+
"@carrick-tools/cli-linux-arm64": "0.3.125",
|
|
119
|
+
"@carrick-tools/cli-linux-x64": "0.3.125",
|
|
120
|
+
"@carrick-tools/cli-win32-x64": "0.3.125"
|
|
121
121
|
},
|
|
122
122
|
"devDependencies": {
|
|
123
123
|
"@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.125",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Carrick",
|
|
7
7
|
"email": "hello@carrick.tools"
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { decisiveAssignmentLine, unwidenedAliasOf, } from './check-probe.js';
|
|
31
31
|
import { scrubDiagnostic, scrubPaths } from './check-scrub.js';
|
|
32
|
+
import { onlyDeclaredOpenAny } from './deep-walk.js';
|
|
32
33
|
import { describeFieldReport, fieldReportNotes, } from './check-fields.js';
|
|
33
34
|
const PRIMARY_RE = /^(?<file>(?:[a-zA-Z]:)?[^(]*?)\((?<line>\d+),(?<col>\d+)\): error TS(?<code>\d+): (?<msg>.*)$/;
|
|
34
35
|
/** Parse `tsc --pretty false` output into structured diagnostics. */
|
|
@@ -350,9 +351,10 @@ function onUnwidenedLines(plan, diagnostic) {
|
|
|
350
351
|
* - its own declarations typecheck (the alias is not poisoned);
|
|
351
352
|
* - the member-level walk ran over it and found no `any`/`unknown`, which
|
|
352
353
|
* would assign to whatever the other side declares;
|
|
353
|
-
* - the same walk found none on the two published sides either
|
|
354
|
-
* that is not a fact stays the
|
|
355
|
-
* clearing it against a member that
|
|
354
|
+
* - the same walk found none on the two published sides either, an open
|
|
355
|
+
* member included (carrick#2591). A mismatch that is not a fact stays the
|
|
356
|
+
* mismatch it was: the reading would be clearing it against a member that
|
|
357
|
+
* accepts anything.
|
|
356
358
|
*/
|
|
357
359
|
function unwidenedReadingFits(input) {
|
|
358
360
|
const { plan, probeDiags, poisonReason } = input;
|
|
@@ -364,7 +366,7 @@ function unwidenedReadingFits(input) {
|
|
|
364
366
|
if (poisonReason(sent.service_name, unwidenedAliasOf(sent.alias)))
|
|
365
367
|
return false;
|
|
366
368
|
const walked = input.deepFindings?.sentUnwidened;
|
|
367
|
-
return walked !== undefined && walked.length === 0 && factness(input).resolved;
|
|
369
|
+
return walked !== undefined && walked.length === 0 && factness(input, true).resolved;
|
|
368
370
|
}
|
|
369
371
|
/**
|
|
370
372
|
* The field-level sentence appended to a mismatch diagnostic
|
|
@@ -410,7 +412,7 @@ function namedFields(input, scrubCtx) {
|
|
|
410
412
|
* empty. A walk that could not run leaves the verdict not-a-fact: absence of
|
|
411
413
|
* findings is not evidence.
|
|
412
414
|
*/
|
|
413
|
-
function factness(input) {
|
|
415
|
+
function factness(input, strict = false) {
|
|
414
416
|
const findings = input.deepFindings;
|
|
415
417
|
if (!findings) {
|
|
416
418
|
return {
|
|
@@ -419,6 +421,10 @@ function factness(input) {
|
|
|
419
421
|
};
|
|
420
422
|
}
|
|
421
423
|
for (const side of ['sent', 'expected']) {
|
|
424
|
+
// Open members only (carrick#2591): the closed members were judged and an
|
|
425
|
+
// open one is never a mismatch, so the verdict is a fact about the type.
|
|
426
|
+
if (!strict && onlyDeclaredOpenAny(findings[side]))
|
|
427
|
+
continue;
|
|
422
428
|
const first = findings[side][0];
|
|
423
429
|
if (!first)
|
|
424
430
|
continue;
|
|
@@ -25,6 +25,7 @@ import { scrubPaths } from './check-scrub.js';
|
|
|
25
25
|
import { assembleWorkspace, writeProbes, } from './check-workspace.js';
|
|
26
26
|
import { buildPoisonIndexes } from './check-poison.js';
|
|
27
27
|
import { openProbeProgram, probeDeepFindings } from './check-deep.js';
|
|
28
|
+
import { onlyDeclaredOpenAny } from './deep-walk.js';
|
|
28
29
|
import { pairFieldReports } from './check-fields.js';
|
|
29
30
|
import { pairDispatchUnions } from './check-union.js';
|
|
30
31
|
function runProcess(command, args, cwd, env) {
|
|
@@ -465,7 +466,13 @@ function deepDecayOf(plan, aliasRecords) {
|
|
|
465
466
|
const record = aliasRecords.get(endpoint.service_name)?.get(endpoint.alias);
|
|
466
467
|
// The capture emits every deep finding it made; the FIRST is the one the
|
|
467
468
|
// pre-gate reads, exactly as when the walk stopped at the first hit.
|
|
468
|
-
const
|
|
469
|
+
const findings = record?.any_provenance ?? [];
|
|
470
|
+
// An `any` the source declares under a named member is an open member
|
|
471
|
+
// (carrick#2591): the pair is probed, and the compiler judges the closed
|
|
472
|
+
// members. Anything else on the record keeps the pair out of the probe.
|
|
473
|
+
if (onlyDeclaredOpenAny(findings))
|
|
474
|
+
continue;
|
|
475
|
+
const finding = findings[0];
|
|
469
476
|
if (finding) {
|
|
470
477
|
return { side, kind: finding.kind, path: finding.path };
|
|
471
478
|
}
|
|
@@ -20,6 +20,14 @@ import type { TypeProvenance } from './api.js';
|
|
|
20
20
|
* within its depth/node budget, treated as unverifiable (fail closed) rather
|
|
21
21
|
* than silently clean. */
|
|
22
22
|
export type DeepTopType = Pick<TypeProvenance, 'kind' | 'path'>;
|
|
23
|
+
/** Cap on findings reported per alias. The FIRST one is what the check phase
|
|
24
|
+
* pre-gates on, so verdicts never depend on this number; the rest are there to
|
|
25
|
+
* tell a reader which fields are `any` (carrick#376), and a type with more
|
|
26
|
+
* than this many is already better described by "this type is not typed".
|
|
27
|
+
* The scanner mirrors it (`CAPTURE_FINDINGS_CAP` in
|
|
28
|
+
* `src/engine/type_compat_v2.rs`): a record this long may have stopped
|
|
29
|
+
* listing, so it cannot vouch for every position (carrick#1752). */
|
|
30
|
+
export declare const MAX_DEEP_FINDINGS = 32;
|
|
23
31
|
/**
|
|
24
32
|
* Depth-bounded structural walk for a disqualifying top type at ANY depth:
|
|
25
33
|
* a member, array element, index signature, type argument, or callable RETURN
|
|
@@ -76,6 +84,29 @@ export declare function findUncarriedPositions(root: ts.Type, program: ts.Progra
|
|
|
76
84
|
/** TypeScript's unresolved-reference placeholder: `TypeFlags.Any` with the
|
|
77
85
|
* internal `intrinsicName === 'error'` (stable since TS 1.x; see `anchors.ts`). */
|
|
78
86
|
export declare function isErrorPlaceholder(t: ts.Type): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* True when a deep-walk path passes through a named member (the same rule as
|
|
89
|
+
* `path_names_a_member` in `src/engine/type_compat_v2.rs`). The walk writes a
|
|
90
|
+
* member as its name, an element or type argument as `<n>`, an index signature
|
|
91
|
+
* as `[index]` and a callable return as `()`; a path made of the last three
|
|
92
|
+
* alone sits at the root of the type or inside a container, never in a field.
|
|
93
|
+
*/
|
|
94
|
+
export declare function pathNamesAMember(path: string): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* True when every finding is an `any` the SOURCE declares under a named
|
|
97
|
+
* member (carrick#2591): an OPEN member. The policy: an open member is never
|
|
98
|
+
* a mismatch, and the closed members are judged as for a fully typed pair.
|
|
99
|
+
* `any` accepts and supplies every shape in the assignment the probe makes, so
|
|
100
|
+
* the compiler already judges only the closed members.
|
|
101
|
+
*
|
|
102
|
+
* Nothing else qualifies. A callable's return (`getData()`) is no data
|
|
103
|
+
* member. An `unknown` member rejects a narrower counterparty
|
|
104
|
+
* under assignment, so it would read as a mismatch the policy forbids; a
|
|
105
|
+
* `budget_exhausted` sentinel, a root top type and an `any` an unresolved
|
|
106
|
+
* import left say nothing about the author's intent. A list as long as the
|
|
107
|
+
* walk's cap may have stopped listing, so it cannot vouch for every position.
|
|
108
|
+
*/
|
|
109
|
+
export declare function onlyDeclaredOpenAny(findings: readonly TypeProvenance[]): boolean;
|
|
79
110
|
/** What an anchor's SOURCE program could not resolve (carrick#1164). */
|
|
80
111
|
export interface UnresolvedAtAnchor {
|
|
81
112
|
/** Member paths holding the unresolved-reference placeholder. */
|
|
@@ -22,7 +22,7 @@ import { memberPath } from './member-name.js';
|
|
|
22
22
|
* The scanner mirrors it (`CAPTURE_FINDINGS_CAP` in
|
|
23
23
|
* `src/engine/type_compat_v2.rs`): a record this long may have stopped
|
|
24
24
|
* listing, so it cannot vouch for every position (carrick#1752). */
|
|
25
|
-
const MAX_DEEP_FINDINGS = 32;
|
|
25
|
+
export const MAX_DEEP_FINDINGS = 32;
|
|
26
26
|
/**
|
|
27
27
|
* Depth-bounded structural walk for a disqualifying top type at ANY depth:
|
|
28
28
|
* a member, array element, index signature, type argument, or callable RETURN
|
|
@@ -269,6 +269,48 @@ function walkTopTypes(root, program, checker, location, flagOf) {
|
|
|
269
269
|
rest.sort((a, b) => a.path.localeCompare(b.path));
|
|
270
270
|
return [head, ...rest];
|
|
271
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* True when a deep-walk path passes through a named member (the same rule as
|
|
274
|
+
* `path_names_a_member` in `src/engine/type_compat_v2.rs`). The walk writes a
|
|
275
|
+
* member as its name, an element or type argument as `<n>`, an index signature
|
|
276
|
+
* as `[index]` and a callable return as `()`; a path made of the last three
|
|
277
|
+
* alone sits at the root of the type or inside a container, never in a field.
|
|
278
|
+
*/
|
|
279
|
+
export function pathNamesAMember(path) {
|
|
280
|
+
const rest = path.replaceAll('[index]', '').replaceAll('()', '');
|
|
281
|
+
let inArgument = false;
|
|
282
|
+
for (const c of rest) {
|
|
283
|
+
if (c === '<')
|
|
284
|
+
inArgument = true;
|
|
285
|
+
else if (c === '>')
|
|
286
|
+
inArgument = false;
|
|
287
|
+
else if (c !== '.' && !inArgument)
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* True when every finding is an `any` the SOURCE declares under a named
|
|
294
|
+
* member (carrick#2591): an OPEN member. The policy: an open member is never
|
|
295
|
+
* a mismatch, and the closed members are judged as for a fully typed pair.
|
|
296
|
+
* `any` accepts and supplies every shape in the assignment the probe makes, so
|
|
297
|
+
* the compiler already judges only the closed members.
|
|
298
|
+
*
|
|
299
|
+
* Nothing else qualifies. A callable's return (`getData()`) is no data
|
|
300
|
+
* member. An `unknown` member rejects a narrower counterparty
|
|
301
|
+
* under assignment, so it would read as a mismatch the policy forbids; a
|
|
302
|
+
* `budget_exhausted` sentinel, a root top type and an `any` an unresolved
|
|
303
|
+
* import left say nothing about the author's intent. A list as long as the
|
|
304
|
+
* walk's cap may have stopped listing, so it cannot vouch for every position.
|
|
305
|
+
*/
|
|
306
|
+
export function onlyDeclaredOpenAny(findings) {
|
|
307
|
+
return (findings.length > 0 &&
|
|
308
|
+
findings.length < MAX_DEEP_FINDINGS &&
|
|
309
|
+
findings.every((f) => f.kind === 'any' &&
|
|
310
|
+
f.reason === 'declared' &&
|
|
311
|
+
pathNamesAMember(f.path) &&
|
|
312
|
+
!f.path.includes('()')));
|
|
313
|
+
}
|
|
272
314
|
/** The disqualifier the self-check and the check phase gate on (see
|
|
273
315
|
* `findDisqualifyingTopTypes`). */
|
|
274
316
|
function disqualifyingFlag(t) {
|
|
@@ -34,7 +34,7 @@ export { sidecarCompilerOptions } from './compiler-options.js';
|
|
|
34
34
|
export { isInsideCheckout, untrustedReadBound, UNTRUSTED_TREE_ENV, type ReadBound } from './checkout.js';
|
|
35
35
|
export { serviceConfigPath } from './project-references.js';
|
|
36
36
|
export { referenceRedirectOf } from './reference-redirect.js';
|
|
37
|
-
export { findServiceTsconfig } from './service-config.js';
|
|
37
|
+
export { findServiceTsconfig, fileConfigPath } from './service-config.js';
|
|
38
38
|
export { WriteGuard } from './guarded-fs.js';
|
|
39
39
|
export { installedPackageDirectory } from './installed-package-directory.js';
|
|
40
40
|
export { isDeclarationFileName, packageNameOf } from './specifiers.js';
|
|
@@ -52,7 +52,7 @@ export { sidecarCompilerOptions } from './compiler-options.js';
|
|
|
52
52
|
export { isInsideCheckout, untrustedReadBound, UNTRUSTED_TREE_ENV } from './checkout.js';
|
|
53
53
|
export { serviceConfigPath } from './project-references.js';
|
|
54
54
|
export { referenceRedirectOf } from './reference-redirect.js';
|
|
55
|
-
export { findServiceTsconfig } from './service-config.js';
|
|
55
|
+
export { findServiceTsconfig, fileConfigPath } from './service-config.js';
|
|
56
56
|
// carrick#2405: the per-position rows write through the guard, and name an
|
|
57
57
|
// installed declaring file the way the capture's resolution walk does.
|
|
58
58
|
export { WriteGuard } from './guarded-fs.js';
|
|
@@ -39,6 +39,12 @@ export const MACHINERY_MEMBER_INDICATORS = new Set([
|
|
|
39
39
|
'clone',
|
|
40
40
|
'json',
|
|
41
41
|
'statusText',
|
|
42
|
+
// FROZEN (old path, 2026-10-21): the reply-object half below only (the
|
|
43
|
+
// fetch half above and the threshold stay). Deletions and safety fixes
|
|
44
|
+
// only. Fixes for wrong or missing types go in labelled reply objects
|
|
45
|
+
// (carrick#2472 C4) and the language library rows (carrick#2445). Delete
|
|
46
|
+
// when a delete-and-replay on the four repos moves no row wrongly, after
|
|
47
|
+
// carrick-cloud#2379 and carrick#2445 (carrick#2472, C6).
|
|
42
48
|
// Node http ServerResponse / reply-object surface
|
|
43
49
|
'statusCode',
|
|
44
50
|
'statusMessage',
|
|
@@ -19,3 +19,14 @@
|
|
|
19
19
|
export declare function findServiceTsconfig(serviceRoot: string, scanRoot?: string): string | undefined;
|
|
20
20
|
/** The path with every symlink resolved; the path as given when it does not exist. */
|
|
21
21
|
export declare function realPath(p: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* The config an editor types `file` under, for a service with none at its
|
|
24
|
+
* root (carrick#1604): the nearest `tsconfig.json`, then `jsconfig.json`, in
|
|
25
|
+
* each directory from the file's up to the service root, inclusive. The
|
|
26
|
+
* climb stops after a directory named `node_modules`, as tsserver's does. A file
|
|
27
|
+
* outside the service root, or with no config on its way up, has none.
|
|
28
|
+
*
|
|
29
|
+
* `known` caches the answer per directory; pass one map for one service so
|
|
30
|
+
* its files share the climb.
|
|
31
|
+
*/
|
|
32
|
+
export declare function fileConfigPath(file: string, serviceRoot: string, known?: Map<string, string | undefined>): string | undefined;
|
|
@@ -55,3 +55,43 @@ export function realPath(p) {
|
|
|
55
55
|
return path.resolve(p);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
/** The names an editor looks for in a file's directory, in order. */
|
|
59
|
+
const FILE_CONFIG_NAMES = ['tsconfig.json', 'jsconfig.json'];
|
|
60
|
+
/**
|
|
61
|
+
* The config an editor types `file` under, for a service with none at its
|
|
62
|
+
* root (carrick#1604): the nearest `tsconfig.json`, then `jsconfig.json`, in
|
|
63
|
+
* each directory from the file's up to the service root, inclusive. The
|
|
64
|
+
* climb stops after a directory named `node_modules`, as tsserver's does. A file
|
|
65
|
+
* outside the service root, or with no config on its way up, has none.
|
|
66
|
+
*
|
|
67
|
+
* `known` caches the answer per directory; pass one map for one service so
|
|
68
|
+
* its files share the climb.
|
|
69
|
+
*/
|
|
70
|
+
export function fileConfigPath(file, serviceRoot, known = new Map()) {
|
|
71
|
+
const root = path.resolve(serviceRoot);
|
|
72
|
+
const climbed = [];
|
|
73
|
+
let found;
|
|
74
|
+
let dir = path.dirname(path.resolve(file));
|
|
75
|
+
for (;;) {
|
|
76
|
+
const from = path.relative(root, dir);
|
|
77
|
+
if (from.startsWith('..') || path.isAbsolute(from))
|
|
78
|
+
break;
|
|
79
|
+
if (known.has(dir)) {
|
|
80
|
+
found = known.get(dir);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
climbed.push(dir);
|
|
84
|
+
const name = FILE_CONFIG_NAMES.find((n) => fs.existsSync(path.join(dir, n)));
|
|
85
|
+
if (name !== undefined) {
|
|
86
|
+
found = path.join(dir, name);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
// tsserver looks in a `node_modules` directory itself, then stops.
|
|
90
|
+
if (dir === root || path.basename(dir) === 'node_modules')
|
|
91
|
+
break;
|
|
92
|
+
dir = path.dirname(dir);
|
|
93
|
+
}
|
|
94
|
+
for (const d of climbed)
|
|
95
|
+
known.set(d, found);
|
|
96
|
+
return found;
|
|
97
|
+
}
|
|
@@ -442,7 +442,7 @@ function handleVerifyLibraryClaims(request) {
|
|
|
442
442
|
writeProgress(request.request_id, 'verify_library_claims', 'program ready');
|
|
443
443
|
const fromDir = path.resolve(projectLoader.getRepoRoot(), request.from_dir);
|
|
444
444
|
log(`Verifying ${request.checks.length} library claim(s) from ${fromDir}`);
|
|
445
|
-
const { semantics, modules } = claimsVerifier.run(fromDir, request.checks, request.budget_ms ?? SEMANTICS_BUDGET_MS);
|
|
445
|
+
const { semantics, modules } = claimsVerifier.run(fromDir, request.checks, request.budget_ms ?? SEMANTICS_BUDGET_MS, request.scan_root);
|
|
446
446
|
return {
|
|
447
447
|
request_id: request.request_id,
|
|
448
448
|
status: 'success',
|
|
@@ -63,7 +63,7 @@ export declare class LibraryClaimsVerifier {
|
|
|
63
63
|
* A check on an instance or a scope reads the maker or scope claims of the
|
|
64
64
|
* same request first, whatever their place in it.
|
|
65
65
|
*/
|
|
66
|
-
run(fromDir: string, checks: LibraryCheck[], budgetMs: number): VerifyResult;
|
|
66
|
+
run(fromDir: string, checks: LibraryCheck[], budgetMs: number, scanRoot?: string): VerifyResult;
|
|
67
67
|
/**
|
|
68
68
|
* Each specifier's declared surface, read with the verifier's predicates
|
|
69
69
|
* (carrick#1660): every value export (or only those `only` names for the
|
|
@@ -39,6 +39,7 @@ import * as crypto from 'node:crypto';
|
|
|
39
39
|
import * as fs from 'node:fs';
|
|
40
40
|
import * as path from 'node:path';
|
|
41
41
|
import { ts } from 'ts-morph';
|
|
42
|
+
import { PathNames, declaringFileName } from './position-rows.js';
|
|
42
43
|
/** The methods a `verb` claim may name, and a method key may accept. */
|
|
43
44
|
const HTTP_METHODS = new Set([
|
|
44
45
|
'GET',
|
|
@@ -130,8 +131,9 @@ const HTTP_V2_RULES = { http: false, message: true, ops: new Set(['request']), m
|
|
|
130
131
|
const SERVER_RULES = { http: false, message: true, ops: new Set(['receive']), methods: ROUTE_METHODS };
|
|
131
132
|
/**
|
|
132
133
|
* The role table: the role alone picks the checks. A role this build does
|
|
133
|
-
* not read yet (GraphQL executors
|
|
134
|
-
*
|
|
134
|
+
* not read yet (GraphQL executors) answers `role_unsupported`, and `none`
|
|
135
|
+
* has nothing on the wire to check. A server's mounts are scope claims
|
|
136
|
+
* (`mounts`, #2474 R0).
|
|
135
137
|
*/
|
|
136
138
|
const ROLE_TABLE = {
|
|
137
139
|
http_client: HTTP_RULES,
|
|
@@ -238,6 +240,13 @@ export function httpCheck(check) {
|
|
|
238
240
|
}
|
|
239
241
|
}
|
|
240
242
|
}
|
|
243
|
+
/** Declarations in a fixed order, each once. */
|
|
244
|
+
function sortedDeclarations(declarations) {
|
|
245
|
+
const seen = new Map();
|
|
246
|
+
for (const each of declarations)
|
|
247
|
+
seen.set(`${each.decl_file}:${each.decl_line}`, each);
|
|
248
|
+
return [...seen.values()].sort((a, b) => (a.decl_file < b.decl_file ? -1 : a.decl_file > b.decl_file ? 1 : a.decl_line - b.decl_line));
|
|
249
|
+
}
|
|
241
250
|
let probeSequence = 0;
|
|
242
251
|
export class LibraryClaimsVerifier {
|
|
243
252
|
project;
|
|
@@ -250,10 +259,15 @@ export class LibraryClaimsVerifier {
|
|
|
250
259
|
* A check on an instance or a scope reads the maker or scope claims of the
|
|
251
260
|
* same request first, whatever their place in it.
|
|
252
261
|
*/
|
|
253
|
-
run(fromDir, checks, budgetMs) {
|
|
262
|
+
run(fromDir, checks, budgetMs, scanRoot) {
|
|
254
263
|
const deadline = performance.now() + budgetMs;
|
|
255
264
|
const stamp = (check, outcome) => outcome.verdict === 'verified'
|
|
256
|
-
? {
|
|
265
|
+
? {
|
|
266
|
+
claim_id: check.claim_id,
|
|
267
|
+
receiver: check.receiver,
|
|
268
|
+
verdict: 'verified',
|
|
269
|
+
...(outcome.declarations?.length ? { declarations: outcome.declarations } : {}),
|
|
270
|
+
}
|
|
257
271
|
: {
|
|
258
272
|
claim_id: check.claim_id,
|
|
259
273
|
receiver: check.receiver,
|
|
@@ -330,7 +344,7 @@ export class LibraryClaimsVerifier {
|
|
|
330
344
|
const file = program.getSourceFile(probe.getFilePath());
|
|
331
345
|
if (!file)
|
|
332
346
|
throw new Error(`probe file ${probePath} is not in the program`);
|
|
333
|
-
const reader = new DeclarationReader(program, file, this.project.getModuleResolutionHost(), fromDir);
|
|
347
|
+
const reader = new DeclarationReader(program, file, this.project.getModuleResolutionHost(), fromDir, scanRoot);
|
|
334
348
|
const localRanges = readLocalRanges(fromDir);
|
|
335
349
|
const moduleReads = new Map();
|
|
336
350
|
const exportReads = new Map();
|
|
@@ -501,13 +515,15 @@ export class LibraryClaimsVerifier {
|
|
|
501
515
|
const built = builtReceiver(check);
|
|
502
516
|
const builtAtDefaults = built === undefined ? undefined : atDefaults(check, built);
|
|
503
517
|
const holding = [];
|
|
518
|
+
const declarations = [];
|
|
504
519
|
for (const receiver of receivers.value) {
|
|
505
520
|
const result = reader.judgeMessage(receiver, check.claim, rules, builtAtDefaults);
|
|
506
521
|
if (result.outcome.verdict !== 'verified')
|
|
507
522
|
return result;
|
|
508
523
|
holding.push(...(result.holding ?? []));
|
|
524
|
+
declarations.push(...(result.outcome.declarations ?? []));
|
|
509
525
|
}
|
|
510
|
-
return { outcome: VERIFIED, holding };
|
|
526
|
+
return { outcome: declarations.length > 0 ? { verdict: 'verified', declarations: sortedDeclarations(declarations) } : VERIFIED, holding };
|
|
511
527
|
};
|
|
512
528
|
const semantics = [];
|
|
513
529
|
for (let index = 0; index < checks.length; index++) {
|
|
@@ -695,6 +711,19 @@ function placementInvalid(claim) {
|
|
|
695
711
|
const of = parseReceiver(claim.of);
|
|
696
712
|
return of === undefined || (of.maker === undefined && of.scope === undefined);
|
|
697
713
|
}
|
|
714
|
+
/**
|
|
715
|
+
* Where a handler's request holds its path and method (#2474 R0) is read on
|
|
716
|
+
* a receive op with a handler, and names `path` or `url`; every member path
|
|
717
|
+
* it gives is non-empty.
|
|
718
|
+
*/
|
|
719
|
+
function requestAtValid(claim) {
|
|
720
|
+
const request = claim.request;
|
|
721
|
+
if (claim.op !== 'receive' || claim.handler === undefined)
|
|
722
|
+
return false;
|
|
723
|
+
if (request.path === undefined && request.url === undefined)
|
|
724
|
+
return false;
|
|
725
|
+
return [request.path, request.url, request.method].every(members => members === undefined || members.length > 0);
|
|
726
|
+
}
|
|
698
727
|
/**
|
|
699
728
|
* A message check names a receiver its claim can be read on (contract
|
|
700
729
|
* amendment 2, B3). A maker is read on the export itself. An element with
|
|
@@ -841,6 +870,8 @@ class DeclarationReader {
|
|
|
841
870
|
probe;
|
|
842
871
|
host;
|
|
843
872
|
checker;
|
|
873
|
+
/** Names declaring files as position rows do: from the scan root, else the service root. */
|
|
874
|
+
pathNames;
|
|
844
875
|
/** The service root, as a realpath. */
|
|
845
876
|
root;
|
|
846
877
|
packageDirectories = new Map();
|
|
@@ -853,11 +884,12 @@ class DeclarationReader {
|
|
|
853
884
|
declared = new Map();
|
|
854
885
|
/** The service root as given and as a realpath, longest first: what a listing prints as `<root>`. */
|
|
855
886
|
printedRoots;
|
|
856
|
-
constructor(program, probe, host, serviceRoot) {
|
|
887
|
+
constructor(program, probe, host, serviceRoot, scanRoot) {
|
|
857
888
|
this.program = program;
|
|
858
889
|
this.probe = probe;
|
|
859
890
|
this.host = host;
|
|
860
891
|
this.checker = program.getTypeChecker();
|
|
892
|
+
this.pathNames = new PathNames(scanRoot ?? serviceRoot);
|
|
861
893
|
this.root = this.realpath(serviceRoot);
|
|
862
894
|
this.printedRoots = [...new Set([this.root, serviceRoot])]
|
|
863
895
|
.filter(root => root.length > 1)
|
|
@@ -1472,9 +1504,26 @@ class DeclarationReader {
|
|
|
1472
1504
|
* `member` (after `path`) is a home-declared callable member whose name
|
|
1473
1505
|
* slot accepts a string, under the name-slot rules, and returns a type that
|
|
1474
1506
|
* says something, read at its type-parameter defaults (`returnOf`).
|
|
1507
|
+
*
|
|
1508
|
+
* Route fields (#2474 R0, contract amendment of 2026-10-22 on carrick#2434):
|
|
1509
|
+
* - `runs` with `yields`: the member runs the function at `runs` (a
|
|
1510
|
+
* function with a declared signature) and hands it, at `yields.param`,
|
|
1511
|
+
* an instance of the package's export `yields.export` (`yieldsOutcome`);
|
|
1512
|
+
* - `mounts`: the receiver the claim is read on fits the parameter there
|
|
1513
|
+
* (`mountsOutcome`).
|
|
1514
|
+
* Either one binds its instance through a parameter or an argument, not
|
|
1515
|
+
* the member's return, so the return is not read and the claim holds no
|
|
1516
|
+
* overloads: a `>scope:` receiver read through it is not resolved.
|
|
1517
|
+
* `runs` without `yields` (or the reverse), or `mounts` beside either, is
|
|
1518
|
+
* `claim_invalid`.
|
|
1475
1519
|
*/
|
|
1476
1520
|
judgeScope(receiver, claim, built) {
|
|
1477
|
-
|
|
1521
|
+
if ((claim.runs === undefined) !== (claim.yields === undefined))
|
|
1522
|
+
return { outcome: unchecked('claim_invalid') };
|
|
1523
|
+
if (claim.mounts !== undefined && claim.runs !== undefined)
|
|
1524
|
+
return { outcome: unchecked('claim_invalid') };
|
|
1525
|
+
const binds = claim.runs !== undefined || claim.mounts !== undefined;
|
|
1526
|
+
const layout = this.layout({ name: claim.name, runs: claim.runs, mounts: claim.mounts });
|
|
1478
1527
|
if ('failure' in layout)
|
|
1479
1528
|
return { outcome: layout.failure };
|
|
1480
1529
|
const callee = this.ownCallee(receiver, claim.path ?? [], claim.member, 'call');
|
|
@@ -1485,7 +1534,17 @@ class DeclarationReader {
|
|
|
1485
1534
|
let best;
|
|
1486
1535
|
for (const signature of callee.signatures) {
|
|
1487
1536
|
let result = this.messageSignatureOutcome(signature, layout.value, labels);
|
|
1488
|
-
if (result.rank === Infinity &&
|
|
1537
|
+
if (result.rank === Infinity && claim.yields !== undefined) {
|
|
1538
|
+
const given = this.yieldsOutcome(signature, claim.runs, claim.yields, receiver);
|
|
1539
|
+
if (given)
|
|
1540
|
+
result = { rank: 5, outcome: given };
|
|
1541
|
+
}
|
|
1542
|
+
if (result.rank === Infinity && claim.mounts !== undefined) {
|
|
1543
|
+
const mounted = this.mountsOutcome(signature, claim.mounts, receiver);
|
|
1544
|
+
if (mounted)
|
|
1545
|
+
result = { rank: 5, outcome: mounted };
|
|
1546
|
+
}
|
|
1547
|
+
if (result.rank === Infinity && !binds && this.returnSaysNothing(this.returnOf(signature, built))) {
|
|
1489
1548
|
result = { rank: 6, outcome: unchecked('scope_unresolved') };
|
|
1490
1549
|
}
|
|
1491
1550
|
if (result.rank === Infinity)
|
|
@@ -1494,9 +1553,38 @@ class DeclarationReader {
|
|
|
1494
1553
|
best = furthest(best, result.rank, result.outcome);
|
|
1495
1554
|
}
|
|
1496
1555
|
if (holding.length > 0)
|
|
1497
|
-
return { outcome: VERIFIED, holding };
|
|
1556
|
+
return binds ? { outcome: VERIFIED } : { outcome: VERIFIED, holding };
|
|
1498
1557
|
return { outcome: best.outcome };
|
|
1499
1558
|
}
|
|
1559
|
+
/**
|
|
1560
|
+
* `mounts: { arg, key? }` on a scope (#2474 R0): the receiver the claim is
|
|
1561
|
+
* read on is assignable to the parameter there, so an instance like it can
|
|
1562
|
+
* be served under the scope's name. A parameter typed `any`, `unknown`,
|
|
1563
|
+
* `{}` or by an unconstrained type parameter says nothing
|
|
1564
|
+
* (`mounts_untyped`). There is no separate "not a function" rule: a router
|
|
1565
|
+
* type that is itself a handler is a common shape, and a parameter only a
|
|
1566
|
+
* function fits fails here because the instance does not fit it.
|
|
1567
|
+
* Undefined when it holds.
|
|
1568
|
+
*/
|
|
1569
|
+
mountsOutcome(signature, mounts, receiver) {
|
|
1570
|
+
let declared = this.keyParameterAt(signature, mounts.arg);
|
|
1571
|
+
if (declared !== undefined && mounts.key !== undefined) {
|
|
1572
|
+
const property = this.declaredProperty(declared, mounts.key);
|
|
1573
|
+
declared = property ? this.checker.getTypeOfSymbol(property) : undefined;
|
|
1574
|
+
}
|
|
1575
|
+
if (declared === undefined)
|
|
1576
|
+
return failed('param_missing');
|
|
1577
|
+
const slot = this.throughConstraint(declared);
|
|
1578
|
+
if (slot === VARIADIC)
|
|
1579
|
+
return unchecked('mounts_untyped');
|
|
1580
|
+
const parts = this.parts(slot);
|
|
1581
|
+
if (parts.length === 0)
|
|
1582
|
+
return failed('mounts_not_instance');
|
|
1583
|
+
if (parts.some(part => this.isOpenTop(part) || this.isUnconstrained(part) || this.isEmptyObject(part))) {
|
|
1584
|
+
return unchecked('mounts_untyped');
|
|
1585
|
+
}
|
|
1586
|
+
return this.checker.isTypeAssignableTo(receiver.type, slot) ? undefined : failed('mounts_not_instance');
|
|
1587
|
+
}
|
|
1500
1588
|
/**
|
|
1501
1589
|
* A send, receive or request op: the home-declared callable member (after
|
|
1502
1590
|
* `path`; or the receiver itself, when null) with some overload that takes
|
|
@@ -1513,6 +1601,9 @@ class DeclarationReader {
|
|
|
1513
1601
|
if (claim.yields !== undefined && (claim.op !== 'receive' || claim.handler === undefined)) {
|
|
1514
1602
|
return unchecked('claim_invalid');
|
|
1515
1603
|
}
|
|
1604
|
+
// So is where the handler's request holds its path and method (#2474 R0).
|
|
1605
|
+
if (claim.request !== undefined && !requestAtValid(claim))
|
|
1606
|
+
return unchecked('claim_invalid');
|
|
1516
1607
|
const name = claim.name;
|
|
1517
1608
|
// A send carries a payload: a name alone on `send(data)` is the data. An
|
|
1518
1609
|
// op that runs a function or client it is handed takes its name from
|
|
@@ -1562,6 +1653,11 @@ class DeclarationReader {
|
|
|
1562
1653
|
if (given)
|
|
1563
1654
|
result = { rank: 5, outcome: given };
|
|
1564
1655
|
}
|
|
1656
|
+
if (result.rank === Infinity && claim.request !== undefined) {
|
|
1657
|
+
const read = this.requestOutcome(signature, claim.handler, claim.request);
|
|
1658
|
+
if (read)
|
|
1659
|
+
result = { rank: 5, outcome: read };
|
|
1660
|
+
}
|
|
1565
1661
|
if (result.rank === Infinity) {
|
|
1566
1662
|
held = true;
|
|
1567
1663
|
break;
|
|
@@ -1572,7 +1668,28 @@ class DeclarationReader {
|
|
|
1572
1668
|
return best.outcome;
|
|
1573
1669
|
if (boundRuns)
|
|
1574
1670
|
return this.boundRunsOutcome(receiver.makerHolding, boundRuns);
|
|
1575
|
-
|
|
1671
|
+
const declarations = this.declarationsOf(callee.signatures);
|
|
1672
|
+
return declarations.length > 0 ? { verdict: 'verified', declarations } : VERIFIED;
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Where the signatures a verified op resolved to are declared, as position
|
|
1676
|
+
* rows spell it (`decl_file`, `decl_line`): the member on this receiver, so
|
|
1677
|
+
* two members of one name on two types give two answers. A declaration no
|
|
1678
|
+
* row could name is left out.
|
|
1679
|
+
*/
|
|
1680
|
+
declarationsOf(signatures) {
|
|
1681
|
+
const found = [];
|
|
1682
|
+
for (const signature of signatures) {
|
|
1683
|
+
const declaration = signature.getDeclaration();
|
|
1684
|
+
if (!declaration)
|
|
1685
|
+
continue;
|
|
1686
|
+
const file = declaration.getSourceFile();
|
|
1687
|
+
const decl_file = declaringFileName(this.program, this.pathNames, file);
|
|
1688
|
+
if (decl_file === null)
|
|
1689
|
+
continue;
|
|
1690
|
+
found.push({ decl_file, decl_line: file.getLineAndCharacterOfPosition(declaration.getStart(file)).line + 1 });
|
|
1691
|
+
}
|
|
1692
|
+
return sortedDeclarations(found);
|
|
1576
1693
|
}
|
|
1577
1694
|
/**
|
|
1578
1695
|
* The method fields of an op (label structure v2, carrick#2434), under the
|
|
@@ -1680,6 +1797,48 @@ class DeclarationReader {
|
|
|
1680
1797
|
}
|
|
1681
1798
|
return failed('yields_not_export');
|
|
1682
1799
|
}
|
|
1800
|
+
/**
|
|
1801
|
+
* `request: { param, path?, url?, method? }` on a receive op (#2474 R0):
|
|
1802
|
+
* some signature of the handler this overload takes declares a parameter
|
|
1803
|
+
* at `param` on which every member path the claim gives resolves, step by
|
|
1804
|
+
* step, through properties the library declares (`declaredProperty`).
|
|
1805
|
+
* Undefined when it holds.
|
|
1806
|
+
*/
|
|
1807
|
+
requestOutcome(signature, handler, request) {
|
|
1808
|
+
let declared = this.keyParameterAt(signature, handler.arg);
|
|
1809
|
+
if (declared !== undefined && handler.key !== undefined) {
|
|
1810
|
+
const property = this.declaredProperty(declared, handler.key);
|
|
1811
|
+
declared = property ? this.checker.getTypeOfSymbol(property) : undefined;
|
|
1812
|
+
}
|
|
1813
|
+
if (declared === undefined)
|
|
1814
|
+
return failed('param_missing');
|
|
1815
|
+
const slot = this.throughConditional(this.throughConstraint(declared));
|
|
1816
|
+
if (slot === VARIADIC)
|
|
1817
|
+
return unchecked('handler_untyped');
|
|
1818
|
+
const paths = [request.path, request.url, request.method].filter((p) => p !== undefined);
|
|
1819
|
+
let best = failed('param_missing');
|
|
1820
|
+
for (const part of this.parts(slot)) {
|
|
1821
|
+
for (const handed of part.getCallSignatures()) {
|
|
1822
|
+
const given = this.parameterAt(handed, request.param);
|
|
1823
|
+
if (given === undefined || given === VARIADIC)
|
|
1824
|
+
continue;
|
|
1825
|
+
const resolves = paths.every(members => {
|
|
1826
|
+
let at = given;
|
|
1827
|
+
for (const member of members) {
|
|
1828
|
+
const property = this.declaredProperty(at, member);
|
|
1829
|
+
if (!property)
|
|
1830
|
+
return false;
|
|
1831
|
+
at = this.checker.getTypeOfSymbol(property);
|
|
1832
|
+
}
|
|
1833
|
+
return true;
|
|
1834
|
+
});
|
|
1835
|
+
if (resolves)
|
|
1836
|
+
return undefined;
|
|
1837
|
+
best = failed('request_member_missing');
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
return best;
|
|
1841
|
+
}
|
|
1683
1842
|
/**
|
|
1684
1843
|
* The declarations spell `name` in one of `member`'s parameters: an
|
|
1685
1844
|
* overload whose parameter there is that literal, or a key of the map a
|