@browserstack/mcp-server 1.5.1-beta.2 → 2.0.0
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/README.md +54 -1
- package/capability/tm.capability-index.json +50419 -0
- package/dist/lib/instrumentation.d.ts +14 -1
- package/dist/lib/instrumentation.js +8 -1
- package/dist/server-factory.js +5 -0
- package/dist/tools/capability-registry/bind.d.ts +28 -0
- package/dist/tools/capability-registry/bind.js +371 -0
- package/dist/tools/capability-registry/config.d.ts +62 -0
- package/dist/tools/capability-registry/config.js +218 -0
- package/dist/tools/capability-registry/discovery.d.ts +44 -0
- package/dist/tools/capability-registry/discovery.js +99 -0
- package/dist/tools/capability-registry/egress.d.ts +44 -0
- package/dist/tools/capability-registry/egress.js +174 -0
- package/dist/tools/capability-registry/index-loader.d.ts +162 -0
- package/dist/tools/capability-registry/index-loader.js +523 -0
- package/dist/tools/capability-registry/redact.d.ts +34 -0
- package/dist/tools/capability-registry/redact.js +98 -0
- package/dist/tools/capability-registry/register.d.ts +48 -0
- package/dist/tools/capability-registry/register.js +708 -0
- package/dist/tools/capability-registry/resolve.d.ts +38 -0
- package/dist/tools/capability-registry/resolve.js +83 -0
- package/dist/tools/capability-registry/search.d.ts +170 -0
- package/dist/tools/capability-registry/search.js +709 -0
- package/dist/tools/capability-registry/types.d.ts +302 -0
- package/dist/tools/capability-registry/types.js +33 -0
- package/package.json +9 -3
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load the index artifact(s) and expose the lookups the tools need.
|
|
3
|
+
*
|
|
4
|
+
* ONE FILE PER PRODUCT is the released contract, so loading is a merge across files. The
|
|
5
|
+
* merged model keeps the products map the rest of the server already reads, which is what
|
|
6
|
+
* lets searchCapability rank across products from N single-product files.
|
|
7
|
+
*/
|
|
8
|
+
import { Capability, ProductIndex, Provenance, RegistryIndex, ResponseDoc, SchemaNode } from "./types.js";
|
|
9
|
+
export declare class IndexError extends Error {
|
|
10
|
+
}
|
|
11
|
+
/** Thrown to the caller as a tool error, so the wording is caller-facing. */
|
|
12
|
+
export declare class InvocationError extends Error {
|
|
13
|
+
}
|
|
14
|
+
/** The file inside a product subdirectory, for the earlier nested layout. */
|
|
15
|
+
export declare const INDEX_FILE = "index.json";
|
|
16
|
+
/** The stored layout, and the name the export publishes: `<product>.capability-index.json`. */
|
|
17
|
+
export declare const FLAT_SUFFIX = ".capability-index.json";
|
|
18
|
+
/**
|
|
19
|
+
* The product a file's LOCATION claims to describe, if its layout says so.
|
|
20
|
+
*
|
|
21
|
+
* `capability/tm.capability-index.json` and `capability/tm/index.json` both name their
|
|
22
|
+
* product, and
|
|
23
|
+
* `fromFiles` cross-checks that against the product key inside. A file in the wrong place
|
|
24
|
+
* would otherwise register its product under the directory's name and answer for endpoints
|
|
25
|
+
* it does not have.
|
|
26
|
+
*/
|
|
27
|
+
export declare function productFromPath(file: string): string | undefined;
|
|
28
|
+
export declare function endpointKey(method: string, path: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Is this capability withheld from the surface?
|
|
31
|
+
*
|
|
32
|
+
* FAILS CLOSED: any truthy `disabled` hides it, not just `=== true`. The flag exists to
|
|
33
|
+
* suppress something that cannot work, so an unexpected shape must hide it rather than be
|
|
34
|
+
* ignored — the opposite default would turn a typo into a silently re-enabled capability.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isDisabled(capability: Capability): boolean;
|
|
37
|
+
export interface LoadedProduct {
|
|
38
|
+
name: string;
|
|
39
|
+
product: ProductIndex;
|
|
40
|
+
provenance: Provenance;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read one artifact file into the product it carries.
|
|
44
|
+
*
|
|
45
|
+
* SHAPE DETECTION, NOT VERSION DETECTION. The released envelope and the pre-release one
|
|
46
|
+
* both declare `schema_version: 1`, so the number cannot tell them apart; the `products`
|
|
47
|
+
* wrapper can, and is the documented discriminator. The pre-release branch is defensive
|
|
48
|
+
* only — that shape was never deployed.
|
|
49
|
+
*/
|
|
50
|
+
export declare function readIndexFile(raw: unknown, source?: string): LoadedProduct;
|
|
51
|
+
export declare class CapabilityRegistry {
|
|
52
|
+
readonly index: RegistryIndex;
|
|
53
|
+
/** product -> provenance of the file it came from. */
|
|
54
|
+
readonly provenance: Record<string, Provenance>;
|
|
55
|
+
/** product -> "METHOD /path" -> capability */
|
|
56
|
+
private readonly byEndpoint;
|
|
57
|
+
/** product -> capability name -> capability. Empty for products that publish no names. */
|
|
58
|
+
private readonly byName;
|
|
59
|
+
/**
|
|
60
|
+
* product -> name AND "METHOD /path" -> the capability that flag withheld.
|
|
61
|
+
*
|
|
62
|
+
* Kept only so an invoke against a handle the caller already holds can say
|
|
63
|
+
* `capability_disabled` instead of `unknown_capability`. Nothing else reads it, and it
|
|
64
|
+
* is deliberately not reachable from `index`.
|
|
65
|
+
*/
|
|
66
|
+
private readonly withheld;
|
|
67
|
+
constructor(index: RegistryIndex, provenance?: Record<string, Provenance>);
|
|
68
|
+
/**
|
|
69
|
+
* The `capability_disabled` message for a handle this build withholds, if it is one.
|
|
70
|
+
*
|
|
71
|
+
* Says THAT it is disabled and nothing about why. The reason is documentation and lives
|
|
72
|
+
* with the decision; repeating it here would put four copies of a paragraph in the
|
|
73
|
+
* shipped artifact to serve a path the flag itself makes rare.
|
|
74
|
+
*
|
|
75
|
+
* Returns undefined for anything else, so the caller falls through to its own
|
|
76
|
+
* not-found wording.
|
|
77
|
+
*/
|
|
78
|
+
private withheldMatch;
|
|
79
|
+
/** How many capabilities each product withholds. Startup logging only. */
|
|
80
|
+
disabledCounts(): Record<string, number>;
|
|
81
|
+
static fromFile(file: string): CapabilityRegistry;
|
|
82
|
+
/**
|
|
83
|
+
* Merge every discovered artifact into one registry.
|
|
84
|
+
*
|
|
85
|
+
* ANY unreadable file fails the whole load, deliberately. Skipping one and carrying on
|
|
86
|
+
* would leave a registry that answers "no such capability" for a product that exists —
|
|
87
|
+
* a confident wrong answer, which is worse than the caller-visible absence of the whole
|
|
88
|
+
* surface (which `register.ts` logs a reason for).
|
|
89
|
+
*/
|
|
90
|
+
static fromFiles(files: string[]): CapabilityRegistry;
|
|
91
|
+
/** The single product's build id, or `name:id` pairs when several are loaded. */
|
|
92
|
+
get buildId(): string;
|
|
93
|
+
productNames(): string[];
|
|
94
|
+
/** Per-product `{build_id, version}`, for logging and cache-busting only. */
|
|
95
|
+
buildInfo(): Record<string, Provenance>;
|
|
96
|
+
/**
|
|
97
|
+
* Find a capability by its published name — the preferred handle.
|
|
98
|
+
*
|
|
99
|
+
* Names are unique within a product but not across products, so an ambiguous name is
|
|
100
|
+
* reported rather than resolved by load order. A name that exists in no index is
|
|
101
|
+
* `unknown_capability`: a distinct outcome from a name that exists elsewhere, because the
|
|
102
|
+
* caller's next move differs — search again, versus pass `product`.
|
|
103
|
+
*/
|
|
104
|
+
byNameLookup(name: string, product?: string): {
|
|
105
|
+
product: string;
|
|
106
|
+
capability: Capability;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Find a capability by the endpoint it exposes — the handle for unnamed products.
|
|
110
|
+
*
|
|
111
|
+
* The endpoint is what searchCapability returns, so it is the only thing a caller can
|
|
112
|
+
* hold. `unknown_endpoint` is a defined outcome rather than a generic failure: a caller
|
|
113
|
+
* working from stale search output needs to know to search again, not to retry.
|
|
114
|
+
*/
|
|
115
|
+
byEndpointLookup(method: string, path: string, product?: string): {
|
|
116
|
+
product: string;
|
|
117
|
+
capability: Capability;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Resolve a `{$response|$schema: "Name"}` reference against the product's lookup tables.
|
|
122
|
+
*
|
|
123
|
+
* ONE HOP. The named component it returns may itself contain references — 39 of tm's 53
|
|
124
|
+
* named responses do — so this is the primitive, not the whole job. Use `resolveResponses`
|
|
125
|
+
* to get a tree with nothing left to look up.
|
|
126
|
+
*
|
|
127
|
+
* A node that is not a reference is returned as-is, and an unresolvable name yields
|
|
128
|
+
* `undefined` rather than throwing: the tables are additive and their absence means "no
|
|
129
|
+
* response schema available".
|
|
130
|
+
*/
|
|
131
|
+
export declare function resolveComponent<T extends ResponseDoc | SchemaNode>(product: ProductIndex, node: T | undefined): T | undefined;
|
|
132
|
+
/**
|
|
133
|
+
* Resolve every reference in a tree, however deep.
|
|
134
|
+
*
|
|
135
|
+
* REFERENCES ARE NESTED, which is the part a one-hop reader gets wrong. They appear at the
|
|
136
|
+
* top of a response (`{"$response": "BadRequest"}`), on its schema
|
|
137
|
+
* (`.../schema/{"$schema": "TestCaseListResponse"}`), and inside the schema's own
|
|
138
|
+
* properties (`.../schema/properties/data/properties/folder`). Chains are real too: a
|
|
139
|
+
* capability's 400 resolves to the named `BadRequest`, whose schema is `{"$schema":
|
|
140
|
+
* "ErrorResponse"}`.
|
|
141
|
+
*
|
|
142
|
+
* AN UNRESOLVABLE REFERENCE IS LEFT IN PLACE, not dropped and not thrown on. A dangling
|
|
143
|
+
* name (the tables are built separately from the capabilities) or a cycle (a folder whose
|
|
144
|
+
* schema contains folders) then shows up as the `{"$schema": "…"}` node it is, which a
|
|
145
|
+
* reader can still act on, rather than as a silently truncated schema.
|
|
146
|
+
*/
|
|
147
|
+
export declare function resolveDeep<T>(product: ProductIndex, node: T): T;
|
|
148
|
+
/** Which declared responses to hand back. */
|
|
149
|
+
export type ResponseSelection = "success" | "all" | "none";
|
|
150
|
+
/**
|
|
151
|
+
* A capability's declared responses with every reference followed.
|
|
152
|
+
*
|
|
153
|
+
* SUCCESS ONLY BY DEFAULT. The error entries are near-identical across the surface — 148 of
|
|
154
|
+
* 173 capabilities declare the same `InternalServerError`, and all of them bottom out in
|
|
155
|
+
* one `ErrorResponse` schema — so including them multiplies a search payload by 6.4x to
|
|
156
|
+
* repeat boilerplate the caller learns from the actual failure anyway. The 2xx entry is the
|
|
157
|
+
* one that says what a successful call returns, which is what a caller needs BEFORE calling.
|
|
158
|
+
*
|
|
159
|
+
* Returns undefined when the capability declares none — which is every capability in an
|
|
160
|
+
* index built before the response tables were added, and is not an error.
|
|
161
|
+
*/
|
|
162
|
+
export declare function resolveResponses(product: ProductIndex, capability: Capability, selection?: ResponseSelection): Record<string, ResponseDoc> | undefined;
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load the index artifact(s) and expose the lookups the tools need.
|
|
3
|
+
*
|
|
4
|
+
* ONE FILE PER PRODUCT is the released contract, so loading is a merge across files. The
|
|
5
|
+
* merged model keeps the products map the rest of the server already reads, which is what
|
|
6
|
+
* lets searchCapability rank across products from N single-product files.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { basename, dirname } from "node:path";
|
|
10
|
+
import { ENVELOPE_KEYS, SUPPORTED_SCHEMA_VERSION, } from "./types.js";
|
|
11
|
+
export class IndexError extends Error {
|
|
12
|
+
}
|
|
13
|
+
/** Thrown to the caller as a tool error, so the wording is caller-facing. */
|
|
14
|
+
export class InvocationError extends Error {
|
|
15
|
+
}
|
|
16
|
+
/** The file inside a product subdirectory, for the earlier nested layout. */
|
|
17
|
+
export const INDEX_FILE = "index.json";
|
|
18
|
+
/** The stored layout, and the name the export publishes: `<product>.capability-index.json`. */
|
|
19
|
+
export const FLAT_SUFFIX = ".capability-index.json";
|
|
20
|
+
/**
|
|
21
|
+
* The product a file's LOCATION claims to describe, if its layout says so.
|
|
22
|
+
*
|
|
23
|
+
* `capability/tm.capability-index.json` and `capability/tm/index.json` both name their
|
|
24
|
+
* product, and
|
|
25
|
+
* `fromFiles` cross-checks that against the product key inside. A file in the wrong place
|
|
26
|
+
* would otherwise register its product under the directory's name and answer for endpoints
|
|
27
|
+
* it does not have.
|
|
28
|
+
*/
|
|
29
|
+
export function productFromPath(file) {
|
|
30
|
+
const base = basename(file);
|
|
31
|
+
if (base.endsWith(FLAT_SUFFIX))
|
|
32
|
+
return base.slice(0, -FLAT_SUFFIX.length);
|
|
33
|
+
if (base === INDEX_FILE)
|
|
34
|
+
return basename(dirname(file));
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
export function endpointKey(method, path) {
|
|
38
|
+
return `${(method || "").trim().toUpperCase()} ${(path || "").trim()}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Is this capability withheld from the surface?
|
|
42
|
+
*
|
|
43
|
+
* FAILS CLOSED: any truthy `disabled` hides it, not just `=== true`. The flag exists to
|
|
44
|
+
* suppress something that cannot work, so an unexpected shape must hide it rather than be
|
|
45
|
+
* ignored — the opposite default would turn a typo into a silently re-enabled capability.
|
|
46
|
+
*/
|
|
47
|
+
export function isDisabled(capability) {
|
|
48
|
+
return Boolean(capability.disabled);
|
|
49
|
+
}
|
|
50
|
+
function isPlainObject(value) {
|
|
51
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Read one artifact file into the product it carries.
|
|
55
|
+
*
|
|
56
|
+
* SHAPE DETECTION, NOT VERSION DETECTION. The released envelope and the pre-release one
|
|
57
|
+
* both declare `schema_version: 1`, so the number cannot tell them apart; the `products`
|
|
58
|
+
* wrapper can, and is the documented discriminator. The pre-release branch is defensive
|
|
59
|
+
* only — that shape was never deployed.
|
|
60
|
+
*/
|
|
61
|
+
export function readIndexFile(raw, source = "index") {
|
|
62
|
+
if (!isPlainObject(raw)) {
|
|
63
|
+
throw new IndexError(`${source}: expected a JSON object`);
|
|
64
|
+
}
|
|
65
|
+
if (raw.schema_version !== SUPPORTED_SCHEMA_VERSION) {
|
|
66
|
+
// Refuse rather than best-effort read: a shape change the generator announced is
|
|
67
|
+
// exactly the case where guessing produces silently wrong tool output.
|
|
68
|
+
throw new IndexError(`${source}: unsupported index schema_version ${raw.schema_version}; this build reads ` +
|
|
69
|
+
`${SUPPORTED_SCHEMA_VERSION}. Rebuild the artifact or update the server.`);
|
|
70
|
+
}
|
|
71
|
+
const provenance = {
|
|
72
|
+
build_id: typeof raw.build_id === "string" ? raw.build_id : "",
|
|
73
|
+
...(typeof raw.version === "string" ? { version: raw.version } : {}),
|
|
74
|
+
};
|
|
75
|
+
if (isPlainObject(raw.products)) {
|
|
76
|
+
// Pre-release shape. Never deployed; recognised so a stray file reads rather than
|
|
77
|
+
// failing in a way that looks like a corrupt artifact.
|
|
78
|
+
const entries = Object.entries(raw.products);
|
|
79
|
+
if (entries.length === 0)
|
|
80
|
+
throw new IndexError(`${source}: contains no products`);
|
|
81
|
+
if (entries.length > 1) {
|
|
82
|
+
throw new IndexError(`${source}: carries ${entries.length} products; one file describes one product`);
|
|
83
|
+
}
|
|
84
|
+
const [name, product] = entries[0];
|
|
85
|
+
return {
|
|
86
|
+
name,
|
|
87
|
+
product: asProduct(product, `${source}:${name}`),
|
|
88
|
+
provenance,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// Released shape: exactly one key that is not part of the envelope.
|
|
92
|
+
const envelope = new Set(ENVELOPE_KEYS);
|
|
93
|
+
const entries = Object.entries(raw).filter(([key, value]) => !envelope.has(key) && isPlainObject(value));
|
|
94
|
+
if (entries.length === 0) {
|
|
95
|
+
throw new IndexError(`${source}: no product object found; expected one top-level key besides ` +
|
|
96
|
+
`${[...envelope].join(", ")}`);
|
|
97
|
+
}
|
|
98
|
+
if (entries.length > 1) {
|
|
99
|
+
throw new IndexError(`${source}: found ${entries.length} candidate product keys ` +
|
|
100
|
+
`(${entries
|
|
101
|
+
.map(([key]) => key)
|
|
102
|
+
.sort()
|
|
103
|
+
.join(", ")}); one file describes one product`);
|
|
104
|
+
}
|
|
105
|
+
const [name, product] = entries[0];
|
|
106
|
+
return { name, product: asProduct(product, `${source}:${name}`), provenance };
|
|
107
|
+
}
|
|
108
|
+
function asProduct(value, source) {
|
|
109
|
+
if (!isPlainObject(value) || !Array.isArray(value.capabilities)) {
|
|
110
|
+
throw new IndexError(`${source}: product object has no capabilities[]`);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Remove withheld names from `entities[*].capabilities[]`.
|
|
116
|
+
*
|
|
117
|
+
* describeEntity returns the entity doc WHOLESALE (`{ ...doc }`), so a name left here is a
|
|
118
|
+
* name the caller sees, looks up, and cannot invoke — the surface would be advertising the
|
|
119
|
+
* capability in the one place that is not a search result.
|
|
120
|
+
*
|
|
121
|
+
* An entity whose list empties is KEPT, not dropped. It still describes a real thing the
|
|
122
|
+
* product models — its id convention, its relations and its key facts remain true and are
|
|
123
|
+
* what a caller resolving an id actually needs — and removing entities would change what
|
|
124
|
+
* listProducts routes on. No tm entity empties today (attachment keeps 4 of 7, dataset 7
|
|
125
|
+
* of 8); this is the rule for when one does.
|
|
126
|
+
*/
|
|
127
|
+
function stripDisabledFromEntities(bundle, withheld) {
|
|
128
|
+
const entities = {};
|
|
129
|
+
for (const [entity, doc] of Object.entries(bundle.entities || {})) {
|
|
130
|
+
const listed = doc?.capabilities;
|
|
131
|
+
if (!Array.isArray(listed)) {
|
|
132
|
+
entities[entity] = doc;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const kept = listed.filter((name) => typeof name !== "string" || !withheld.has(name));
|
|
136
|
+
entities[entity] =
|
|
137
|
+
kept.length === listed.length ? doc : { ...doc, capabilities: kept };
|
|
138
|
+
}
|
|
139
|
+
return entities;
|
|
140
|
+
}
|
|
141
|
+
export class CapabilityRegistry {
|
|
142
|
+
index;
|
|
143
|
+
/** product -> provenance of the file it came from. */
|
|
144
|
+
provenance;
|
|
145
|
+
/** product -> "METHOD /path" -> capability */
|
|
146
|
+
byEndpoint = new Map();
|
|
147
|
+
/** product -> capability name -> capability. Empty for products that publish no names. */
|
|
148
|
+
byName = new Map();
|
|
149
|
+
/**
|
|
150
|
+
* product -> name AND "METHOD /path" -> the capability that flag withheld.
|
|
151
|
+
*
|
|
152
|
+
* Kept only so an invoke against a handle the caller already holds can say
|
|
153
|
+
* `capability_disabled` instead of `unknown_capability`. Nothing else reads it, and it
|
|
154
|
+
* is deliberately not reachable from `index`.
|
|
155
|
+
*/
|
|
156
|
+
withheld = new Map();
|
|
157
|
+
constructor(index, provenance = {}) {
|
|
158
|
+
if (index?.schema_version !== SUPPORTED_SCHEMA_VERSION) {
|
|
159
|
+
throw new IndexError(`unsupported index schema_version ${index?.schema_version}; this build reads ` +
|
|
160
|
+
`${SUPPORTED_SCHEMA_VERSION}. Rebuild the artifact or update the server.`);
|
|
161
|
+
}
|
|
162
|
+
if (!index.products || Object.keys(index.products).length === 0) {
|
|
163
|
+
throw new IndexError("index contains no products");
|
|
164
|
+
}
|
|
165
|
+
this.provenance = provenance;
|
|
166
|
+
// DISABLED CAPABILITIES ARE REMOVED HERE, ONCE, and never reach the rest of the server.
|
|
167
|
+
//
|
|
168
|
+
// The alternative — a `disabled` check at each read site — was rejected: there are four
|
|
169
|
+
// readers of `bundle.capabilities` today (three in search, one in discovery) plus the
|
|
170
|
+
// entity docs, and the cost of a missed one is a capability that is hidden from search
|
|
171
|
+
// and still invokable, which is the exact failure the flag exists to prevent. Filtering
|
|
172
|
+
// the model itself makes the invariant structural instead of remembered.
|
|
173
|
+
const products = {};
|
|
174
|
+
for (const [product, bundle] of Object.entries(index.products)) {
|
|
175
|
+
const lookup = new Map();
|
|
176
|
+
const names = new Map();
|
|
177
|
+
const withheld = new Map();
|
|
178
|
+
const enabled = [];
|
|
179
|
+
for (const capability of bundle.capabilities) {
|
|
180
|
+
// UNIQUENESS IS CHECKED OVER EVERY CAPABILITY, disabled ones included. A clash
|
|
181
|
+
// between a disabled name and a live one is still a broken artifact, and skipping
|
|
182
|
+
// the disabled half would mean re-enabling the flag could fail the load later,
|
|
183
|
+
// somewhere with less context than here.
|
|
184
|
+
if (isDisabled(capability)) {
|
|
185
|
+
withheld.set(endpointKey(capability.method, capability.path), capability);
|
|
186
|
+
if (capability.name)
|
|
187
|
+
withheld.set(capability.name, capability);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
enabled.push(capability);
|
|
191
|
+
lookup.set(endpointKey(capability.method, capability.path), capability);
|
|
192
|
+
}
|
|
193
|
+
if (!capability.name)
|
|
194
|
+
continue;
|
|
195
|
+
const clash = names.get(capability.name);
|
|
196
|
+
if (clash) {
|
|
197
|
+
// A duplicate name makes one of the two permanently unreachable, and which one
|
|
198
|
+
// wins would depend on array order. The export gates this, but a hand-edited or
|
|
199
|
+
// stale artifact must not load and then silently drop an endpoint.
|
|
200
|
+
throw new IndexError(`${product}: capability name '${capability.name}' is used by both ` +
|
|
201
|
+
`${endpointKey(clash.method, clash.path)} and ` +
|
|
202
|
+
`${endpointKey(capability.method, capability.path)}; names must be unique ` +
|
|
203
|
+
`within a product`);
|
|
204
|
+
}
|
|
205
|
+
if (!isDisabled(capability))
|
|
206
|
+
names.set(capability.name, capability);
|
|
207
|
+
}
|
|
208
|
+
this.byEndpoint.set(product, lookup);
|
|
209
|
+
this.byName.set(product, names);
|
|
210
|
+
this.withheld.set(product, withheld);
|
|
211
|
+
products[product] = withheld.size
|
|
212
|
+
? {
|
|
213
|
+
...bundle,
|
|
214
|
+
capabilities: enabled,
|
|
215
|
+
entities: stripDisabledFromEntities(bundle, withheld),
|
|
216
|
+
}
|
|
217
|
+
: bundle;
|
|
218
|
+
}
|
|
219
|
+
this.index = { ...index, products };
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The `capability_disabled` message for a handle this build withholds, if it is one.
|
|
223
|
+
*
|
|
224
|
+
* Says THAT it is disabled and nothing about why. The reason is documentation and lives
|
|
225
|
+
* with the decision; repeating it here would put four copies of a paragraph in the
|
|
226
|
+
* shipped artifact to serve a path the flag itself makes rare.
|
|
227
|
+
*
|
|
228
|
+
* Returns undefined for anything else, so the caller falls through to its own
|
|
229
|
+
* not-found wording.
|
|
230
|
+
*/
|
|
231
|
+
withheldMatch(handle, product) {
|
|
232
|
+
for (const [owner, withheld] of this.withheld) {
|
|
233
|
+
if (product && owner !== product)
|
|
234
|
+
continue;
|
|
235
|
+
if (!withheld.has(handle))
|
|
236
|
+
continue;
|
|
237
|
+
return (`capability_disabled: ${handle} is disabled on this surface. This is ` +
|
|
238
|
+
`deliberate — it is withheld by the ${owner} index, not missing from it — so ` +
|
|
239
|
+
`retrying will not help.`);
|
|
240
|
+
}
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
/** How many capabilities each product withholds. Startup logging only. */
|
|
244
|
+
disabledCounts() {
|
|
245
|
+
const out = {};
|
|
246
|
+
for (const [product, withheld] of this.withheld) {
|
|
247
|
+
// Each capability is keyed twice when it has a name — by endpoint and by name.
|
|
248
|
+
const distinct = new Set(withheld.values());
|
|
249
|
+
if (distinct.size)
|
|
250
|
+
out[product] = distinct.size;
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
static fromFile(file) {
|
|
255
|
+
return CapabilityRegistry.fromFiles([file]);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Merge every discovered artifact into one registry.
|
|
259
|
+
*
|
|
260
|
+
* ANY unreadable file fails the whole load, deliberately. Skipping one and carrying on
|
|
261
|
+
* would leave a registry that answers "no such capability" for a product that exists —
|
|
262
|
+
* a confident wrong answer, which is worse than the caller-visible absence of the whole
|
|
263
|
+
* surface (which `register.ts` logs a reason for).
|
|
264
|
+
*/
|
|
265
|
+
static fromFiles(files) {
|
|
266
|
+
if (files.length === 0)
|
|
267
|
+
throw new IndexError("no index files to load");
|
|
268
|
+
const products = {};
|
|
269
|
+
const provenance = {};
|
|
270
|
+
for (const file of files) {
|
|
271
|
+
let parsed;
|
|
272
|
+
try {
|
|
273
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
throw new IndexError(`${file}: could not be read as JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
277
|
+
}
|
|
278
|
+
const loaded = readIndexFile(parsed, file);
|
|
279
|
+
const claimed = productFromPath(file);
|
|
280
|
+
if (claimed && claimed !== loaded.name) {
|
|
281
|
+
// The name is stated twice — by the path and by the key inside — and they must
|
|
282
|
+
// agree. Trusting either one alone would serve a product's endpoints under the
|
|
283
|
+
// other's name, and every search result would then point at the wrong host.
|
|
284
|
+
throw new IndexError(`${file}: is stored as product '${claimed}' but declares '${loaded.name}'`);
|
|
285
|
+
}
|
|
286
|
+
if (products[loaded.name]) {
|
|
287
|
+
// Two files claiming one product cannot both be right, and picking one silently
|
|
288
|
+
// decides which endpoints exist.
|
|
289
|
+
throw new IndexError(`product '${loaded.name}' is declared by more than one index file; ${file} is a duplicate`);
|
|
290
|
+
}
|
|
291
|
+
products[loaded.name] = loaded.product;
|
|
292
|
+
provenance[loaded.name] = loaded.provenance;
|
|
293
|
+
}
|
|
294
|
+
return new CapabilityRegistry({
|
|
295
|
+
schema_version: SUPPORTED_SCHEMA_VERSION,
|
|
296
|
+
build_id: compositeBuildId(provenance),
|
|
297
|
+
products,
|
|
298
|
+
}, provenance);
|
|
299
|
+
}
|
|
300
|
+
/** The single product's build id, or `name:id` pairs when several are loaded. */
|
|
301
|
+
get buildId() {
|
|
302
|
+
return this.index.build_id;
|
|
303
|
+
}
|
|
304
|
+
productNames() {
|
|
305
|
+
return Object.keys(this.index.products).sort();
|
|
306
|
+
}
|
|
307
|
+
/** Per-product `{build_id, version}`, for logging and cache-busting only. */
|
|
308
|
+
buildInfo() {
|
|
309
|
+
return this.provenance;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Find a capability by its published name — the preferred handle.
|
|
313
|
+
*
|
|
314
|
+
* Names are unique within a product but not across products, so an ambiguous name is
|
|
315
|
+
* reported rather than resolved by load order. A name that exists in no index is
|
|
316
|
+
* `unknown_capability`: a distinct outcome from a name that exists elsewhere, because the
|
|
317
|
+
* caller's next move differs — search again, versus pass `product`.
|
|
318
|
+
*/
|
|
319
|
+
byNameLookup(name, product) {
|
|
320
|
+
const matches = [];
|
|
321
|
+
for (const [owner, lookup] of this.byName) {
|
|
322
|
+
if (product && owner !== product)
|
|
323
|
+
continue;
|
|
324
|
+
const capability = lookup.get(name);
|
|
325
|
+
if (capability)
|
|
326
|
+
matches.push({ product: owner, capability });
|
|
327
|
+
}
|
|
328
|
+
if (matches.length === 0) {
|
|
329
|
+
// WITHHELD IS A DIFFERENT ANSWER FROM ABSENT, and only here. Search cannot surface a
|
|
330
|
+
// disabled capability, so reaching this branch means the caller already held the
|
|
331
|
+
// exact handle — from an older index, a doc, or a human. "unknown_capability" would
|
|
332
|
+
// send it hunting for a synonym that does not exist; the reason ends the attempt.
|
|
333
|
+
const off = this.withheldMatch(name, product);
|
|
334
|
+
if (off)
|
|
335
|
+
throw new InvocationError(off);
|
|
336
|
+
// Say whether names are published at all for the product asked about: "no such name"
|
|
337
|
+
// and "this product does not name its capabilities" need different fixes.
|
|
338
|
+
const unnamed = [...this.byName]
|
|
339
|
+
.filter(([owner, lookup]) => (!product || owner === product) && lookup.size === 0)
|
|
340
|
+
.map(([owner]) => owner);
|
|
341
|
+
const hint = unnamed.length
|
|
342
|
+
? ` ${unnamed.sort().join(", ")} ${unnamed.length > 1 ? "publish" : "publishes"} ` +
|
|
343
|
+
`no capability names yet — call those by \`method\` and \`path\` instead.`
|
|
344
|
+
: " Search again — names come from searchCapability and describeEntity.";
|
|
345
|
+
throw new InvocationError(`unknown_capability: ${name}.${hint}`);
|
|
346
|
+
}
|
|
347
|
+
if (matches.length > 1 && !product) {
|
|
348
|
+
const owners = matches
|
|
349
|
+
.map((m) => m.product)
|
|
350
|
+
.sort()
|
|
351
|
+
.join(", ");
|
|
352
|
+
throw new InvocationError(`capability name '${name}' exists in several products (${owners}); pass product`);
|
|
353
|
+
}
|
|
354
|
+
return matches[0];
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Find a capability by the endpoint it exposes — the handle for unnamed products.
|
|
358
|
+
*
|
|
359
|
+
* The endpoint is what searchCapability returns, so it is the only thing a caller can
|
|
360
|
+
* hold. `unknown_endpoint` is a defined outcome rather than a generic failure: a caller
|
|
361
|
+
* working from stale search output needs to know to search again, not to retry.
|
|
362
|
+
*/
|
|
363
|
+
byEndpointLookup(method, path, product) {
|
|
364
|
+
const key = endpointKey(method, path);
|
|
365
|
+
const matches = [];
|
|
366
|
+
for (const [name, lookup] of this.byEndpoint) {
|
|
367
|
+
if (product && name !== product)
|
|
368
|
+
continue;
|
|
369
|
+
const capability = lookup.get(key);
|
|
370
|
+
if (capability)
|
|
371
|
+
matches.push({ product: name, capability });
|
|
372
|
+
}
|
|
373
|
+
if (matches.length === 0) {
|
|
374
|
+
const off = this.withheldMatch(key, product);
|
|
375
|
+
if (off)
|
|
376
|
+
throw new InvocationError(off);
|
|
377
|
+
throw new InvocationError(`unknown_endpoint: ${key}. Search again — send \`method\` and \`path\` exactly as ` +
|
|
378
|
+
`searchCapability returned them, placeholders included.`);
|
|
379
|
+
}
|
|
380
|
+
if (matches.length > 1 && !product) {
|
|
381
|
+
const owners = matches
|
|
382
|
+
.map((m) => m.product)
|
|
383
|
+
.sort()
|
|
384
|
+
.join(", ");
|
|
385
|
+
throw new InvocationError(`${key} exists in several products (${owners}); pass product`);
|
|
386
|
+
}
|
|
387
|
+
return matches[0];
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function compositeBuildId(provenance) {
|
|
391
|
+
const names = Object.keys(provenance).sort();
|
|
392
|
+
if (names.length === 1)
|
|
393
|
+
return provenance[names[0]].build_id;
|
|
394
|
+
return names.map((name) => `${name}:${provenance[name].build_id}`).join(" ");
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Resolve a `{$response|$schema: "Name"}` reference against the product's lookup tables.
|
|
398
|
+
*
|
|
399
|
+
* ONE HOP. The named component it returns may itself contain references — 39 of tm's 53
|
|
400
|
+
* named responses do — so this is the primitive, not the whole job. Use `resolveResponses`
|
|
401
|
+
* to get a tree with nothing left to look up.
|
|
402
|
+
*
|
|
403
|
+
* A node that is not a reference is returned as-is, and an unresolvable name yields
|
|
404
|
+
* `undefined` rather than throwing: the tables are additive and their absence means "no
|
|
405
|
+
* response schema available".
|
|
406
|
+
*/
|
|
407
|
+
export function resolveComponent(product, node) {
|
|
408
|
+
if (!node || typeof node !== "object")
|
|
409
|
+
return node;
|
|
410
|
+
const ref = node;
|
|
411
|
+
if (typeof ref.$response === "string") {
|
|
412
|
+
return product.responses?.[ref.$response];
|
|
413
|
+
}
|
|
414
|
+
if (typeof ref.$schema === "string") {
|
|
415
|
+
return product.schemas?.[ref.$schema];
|
|
416
|
+
}
|
|
417
|
+
return node;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Resolve every reference in a tree, however deep.
|
|
421
|
+
*
|
|
422
|
+
* REFERENCES ARE NESTED, which is the part a one-hop reader gets wrong. They appear at the
|
|
423
|
+
* top of a response (`{"$response": "BadRequest"}`), on its schema
|
|
424
|
+
* (`.../schema/{"$schema": "TestCaseListResponse"}`), and inside the schema's own
|
|
425
|
+
* properties (`.../schema/properties/data/properties/folder`). Chains are real too: a
|
|
426
|
+
* capability's 400 resolves to the named `BadRequest`, whose schema is `{"$schema":
|
|
427
|
+
* "ErrorResponse"}`.
|
|
428
|
+
*
|
|
429
|
+
* AN UNRESOLVABLE REFERENCE IS LEFT IN PLACE, not dropped and not thrown on. A dangling
|
|
430
|
+
* name (the tables are built separately from the capabilities) or a cycle (a folder whose
|
|
431
|
+
* schema contains folders) then shows up as the `{"$schema": "…"}` node it is, which a
|
|
432
|
+
* reader can still act on, rather than as a silently truncated schema.
|
|
433
|
+
*/
|
|
434
|
+
export function resolveDeep(product, node) {
|
|
435
|
+
return resolveNode(product, node, new Set());
|
|
436
|
+
}
|
|
437
|
+
function resolveNode(product, node, seen) {
|
|
438
|
+
if (Array.isArray(node)) {
|
|
439
|
+
return node.map((item) => resolveNode(product, item, seen));
|
|
440
|
+
}
|
|
441
|
+
if (typeof node !== "object" || node === null)
|
|
442
|
+
return node;
|
|
443
|
+
const ref = node;
|
|
444
|
+
const kind = typeof ref.$response === "string"
|
|
445
|
+
? "$response"
|
|
446
|
+
: typeof ref.$schema === "string"
|
|
447
|
+
? "$schema"
|
|
448
|
+
: undefined;
|
|
449
|
+
if (kind) {
|
|
450
|
+
const name = (kind === "$response" ? ref.$response : ref.$schema);
|
|
451
|
+
const key = `${kind}:${name}`;
|
|
452
|
+
const target = kind === "$response"
|
|
453
|
+
? product.responses?.[name]
|
|
454
|
+
: product.schemas?.[name];
|
|
455
|
+
// Leave the reference visible when it cannot be followed, or when following it would
|
|
456
|
+
// revisit a name already on this path.
|
|
457
|
+
if (!target || seen.has(key))
|
|
458
|
+
return node;
|
|
459
|
+
const resolved = resolveNode(product, target, new Set([...seen, key]));
|
|
460
|
+
// KEYS WRITTEN BESIDE THE REF SURVIVE IT, AND WIN.
|
|
461
|
+
//
|
|
462
|
+
// This used to return the target and drop every sibling, which silently ate 16 nodes
|
|
463
|
+
// in the shipped tm index: 14 authored descriptions and two `nullable: true` flags.
|
|
464
|
+
// The losses are exactly the caveats hardest to rediscover — "only when no step
|
|
465
|
+
// results were submitted", "Present only on root-folder creation", and a `nullable`
|
|
466
|
+
// marking the one field that comes back NULL when a search fails, which is the
|
|
467
|
+
// absent-versus-explicitly-null distinction a caller cannot otherwise make.
|
|
468
|
+
//
|
|
469
|
+
// It also cost real work downstream. A live probe reported one of those caveats as a
|
|
470
|
+
// missing conditional and it had been written all along — this resolver removed it
|
|
471
|
+
// between the author and the caller. Worse, the product team began splitting shared
|
|
472
|
+
// schemas so a caveat would have somewhere to live that survived, trading a lost
|
|
473
|
+
// sentence for two descriptions of one serializer that then drift apart.
|
|
474
|
+
//
|
|
475
|
+
// SIBLINGS WIN over the resolved target, not the reverse. A description written at the
|
|
476
|
+
// reference site is about THAT usage — "only when no step results were submitted" is
|
|
477
|
+
// true of one consumer of TestResult, not of TestResult everywhere — which is the
|
|
478
|
+
// whole reason it was written beside the ref instead of on the schema.
|
|
479
|
+
const siblings = {};
|
|
480
|
+
for (const [field, value] of Object.entries(node)) {
|
|
481
|
+
if (field !== "$schema" && field !== "$response")
|
|
482
|
+
siblings[field] = value;
|
|
483
|
+
}
|
|
484
|
+
if (Object.keys(siblings).length === 0)
|
|
485
|
+
return resolved;
|
|
486
|
+
if (typeof resolved !== "object" ||
|
|
487
|
+
resolved === null ||
|
|
488
|
+
Array.isArray(resolved)) {
|
|
489
|
+
return resolved;
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
...resolved,
|
|
493
|
+
...resolveNode(product, siblings, seen),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
const out = {};
|
|
497
|
+
for (const [field, value] of Object.entries(node)) {
|
|
498
|
+
out[field] = resolveNode(product, value, seen);
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* A capability's declared responses with every reference followed.
|
|
504
|
+
*
|
|
505
|
+
* SUCCESS ONLY BY DEFAULT. The error entries are near-identical across the surface — 148 of
|
|
506
|
+
* 173 capabilities declare the same `InternalServerError`, and all of them bottom out in
|
|
507
|
+
* one `ErrorResponse` schema — so including them multiplies a search payload by 6.4x to
|
|
508
|
+
* repeat boilerplate the caller learns from the actual failure anyway. The 2xx entry is the
|
|
509
|
+
* one that says what a successful call returns, which is what a caller needs BEFORE calling.
|
|
510
|
+
*
|
|
511
|
+
* Returns undefined when the capability declares none — which is every capability in an
|
|
512
|
+
* index built before the response tables were added, and is not an error.
|
|
513
|
+
*/
|
|
514
|
+
export function resolveResponses(product, capability, selection = "success") {
|
|
515
|
+
if (selection === "none" || !capability.responses)
|
|
516
|
+
return undefined;
|
|
517
|
+
const wanted = selection === "all"
|
|
518
|
+
? Object.entries(capability.responses)
|
|
519
|
+
: Object.entries(capability.responses).filter(([status]) => status.startsWith("2"));
|
|
520
|
+
if (wanted.length === 0)
|
|
521
|
+
return undefined;
|
|
522
|
+
return resolveDeep(product, Object.fromEntries(wanted));
|
|
523
|
+
}
|