@webpieces/api-doc-model 0.4.803 → 0.4.805

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 CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  Read a webpieces API contract with the TypeScript compiler API and produce one in-memory `ApiDocModel`.
4
4
 
5
- No renderer, no runtime behaviour, no file output. This is the single extraction pass that OpenAPI documents and MCP tool lists are both rendered from, so the two can never disagree about what the contract says.
5
+ No runtime behaviour and no file output. This is the single extraction pass that OpenAPI documents and MCP tool lists are both rendered from, so the two can never disagree about what the contract says.
6
+
7
+ It carries ONE renderer of its own, `McpSchemaRenderer`, because the MCP projection is not a document — it is the same `ApiJsonSchema` shape `@webpieces/core-util`'s `DtoSchemaBuilder` already produces at boot from reflect-metadata. Rendering it here is what lets the equivalence gate (#983) assert the two are EQUAL, tool by tool and field by field, before #984 takes the MCP runtime off reflection. The OpenAPI document, which genuinely is a document, is `@webpieces/openapi-generator`'s job.
6
8
 
7
9
  ```typescript
8
10
  import { ApiDocExtractor } from '@webpieces/api-doc-model';
@@ -18,4 +20,19 @@ model.types; // ReadonlyMap<string, DocumentedType> — a renderer's $ref target
18
20
  model.unmapped; // UnmappedType[] — recorded, never dropped
19
21
  ```
20
22
 
21
- It depends on `typescript` and nothing else, so it can be pointed at any project's contract. See `responsibilities.md` for what is in and out of scope, why that dependency constraint is the product rather than tidiness, and why both `Integer` and `@WpInt()` are accepted spellings of integer-ness.
23
+ It depends on `typescript` and `@webpieces/core-util`, from which it takes every decorator NAME it matches on — so renaming a decorator is a compile error here rather than a literal that quietly stops matching and empties a generated document. See `responsibilities.md` for what is in and out of scope, why that import is not the coupling it looks like, and why both `Integer` and `@WpInt()` are accepted spellings of integer-ness.
24
+
25
+ ```typescript
26
+ import { ApiDocExtractor, McpSchemaRenderer } from '@webpieces/api-doc-model';
27
+
28
+ // Every `@ApiPath` contract in one file — a repo does not obey one-contract-per-file.
29
+ const models = new ApiDocExtractor().extractAll('/abs/path/to/Fixtures.ts', options);
30
+
31
+ const tools = new McpSchemaRenderer(models[0]).render();
32
+ tools[0].name; // the stable protocol name from @WpMcpTool
33
+ tools[0].description; // the method's JSDoc body, or its `@mcp` tag
34
+ tools[0].hints; // three computed from `operation`, openWorldHint from @Endpoint's options
35
+ tools[0].inputSchema; // ApiJsonSchema, identical to DtoSchemaBuilder's
36
+ ```
37
+
38
+ An MCP header is declared in JSDoc as `@mcpHeader <token>` — the runtime's `WpMcpHeader` argument said the same thing, and #984 deletes it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/api-doc-model",
3
- "version": "0.4.803",
3
+ "version": "0.4.805",
4
4
  "description": "TypeScript-compiler-API extractor producing one in-memory ApiDocModel from a webpieces API contract. No renderer, no runtime, no app-specific import.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -23,6 +23,7 @@
23
23
  "access": "public"
24
24
  },
25
25
  "dependencies": {
26
+ "@webpieces/core-util": "0.4.805",
26
27
  "typescript": "5.9.3"
27
28
  }
28
29
  }
@@ -12,13 +12,12 @@ import { ApiDocModel } from '../model/ApiDocModel';
12
12
  * made of, so the only place they exist is the source, and the only honest way to read the source is
13
13
  * the compiler.
14
14
  *
15
- * ## Why it imports NO webpieces package
15
+ * ## Why it IMPORTS `@webpieces/core-util`
16
16
  *
17
- * It depends on `typescript` and nothing else, so it can be pointed at ANY upstream project's
18
- * contract which is the whole point of shipping it as a package rather than as a script in this
19
- * repo. Decorators are therefore matched BY NAME on the syntax, never by importing the real
20
- * decorator. One `@webpieces/core-util` import here would make the package unusable by anybody whose
21
- * webpieces version differs from ours, which is everybody.
17
+ * Decorators are matched BY NAME on the syntax they must be, because this reads a contract and
18
+ * never executes it. The NAMES come from the real symbols (`Endpoint.name`), so a rename in
19
+ * `core-util` is a compile error here instead of a literal that quietly stops matching. The full
20
+ * argument, and why the import is not the coupling it looks like, is at those constants.
22
21
  *
23
22
  * ## What it does NOT do
24
23
  *
@@ -36,19 +35,93 @@ export declare class ApiDocExtractor {
36
35
  * must be exact (a path constant, a numeric bound) cannot be established.
37
36
  */
38
37
  extractFile(entryFile: string, compilerOptions?: ts.CompilerOptions): ApiDocModel;
38
+ /**
39
+ * EVERY `@ApiPath` contract in one file, in declaration order.
40
+ *
41
+ * {@link extractFile} answers "what is THE contract in this file", which is the shape a manifest
42
+ * entry and a generated document have: one contract, one file. This answers a different question
43
+ * — "what does this file declare" — and it exists because a REPO does not obey that convention.
44
+ * `McpRemoteFixtures.ts` in `@webpieces/mcp-server` declares seven contracts, and the runtime
45
+ * registers MCP tools from all seven; a sweep that read only the first would report green while
46
+ * six contracts' worth of tools had never been looked at, which is the exact shape of silent miss
47
+ * this epic exists to remove.
48
+ *
49
+ * A file with no contract yields an EMPTY list rather than throwing: "this file has none" is an
50
+ * ordinary answer to this question, where it is a failure to answer {@link extractFile}'s.
51
+ */
52
+ extractAll(entryFile: string, compilerOptions?: ts.CompilerOptions): readonly ApiDocModel[];
53
+ /** {@link extractAll} against a program the caller already built. */
54
+ extractAllFrom(program: ts.Program, source: ts.SourceFile): readonly ApiDocModel[];
39
55
  /** The same extraction against a program the caller already built. */
40
56
  extract(program: ts.Program, source: ts.SourceFile): ApiDocModel;
57
+ /** ONE contract class -> ONE model. The single place the walk actually happens. */
58
+ private extractContract;
59
+ /**
60
+ * Extract ONE named type and everything it reaches, from a file that holds NO contract.
61
+ *
62
+ * This exists for a type nothing in a contract points at but a document still publishes — the
63
+ * document-wide error body a renderer's manifest names. Reading it with the SAME resolver is the
64
+ * point: a second reader would be a second answer to "what shape is this type", and the two
65
+ * would drift the first time a field changed.
66
+ *
67
+ * @throws ApiDocExtractionError when the file does not declare that name.
68
+ */
69
+ extractType(entryFile: string, typeName: string, compilerOptions?: ts.CompilerOptions): ApiDocModel;
70
+ /** The interface / class / type alias / enum declared under `name` at the file's top level. */
71
+ private static declarationNamed;
41
72
  /** The one `@ApiPath` class in the file. Zero is a hard failure; the FIRST wins if there are two. */
42
73
  private findContract;
43
74
  /** One `@Endpoint` method. Members without the decorator are not part of the contract. */
44
75
  private endpointOf;
76
+ /**
77
+ * One REQUIRED positional argument of `@Endpoint`, folded to the string it denotes.
78
+ *
79
+ * Every one of the four is an enum member (`POST`, `READ`, `RPC`) as often as it is a literal,
80
+ * and the folder resolves both — a document that printed `RPC` where the trigger goes would be
81
+ * worse than no document. A missing one is a hard failure naming the position, because the
82
+ * alternative is a document quietly missing a verb.
83
+ */
84
+ private requiredArgument;
85
+ /**
86
+ * `@ApiType(...)` on the contract, folded to the real values.
87
+ *
88
+ * A value that is not one of the three documents is a HARD FAILURE rather than a silent drop: a
89
+ * contract that named `CUSTOMER` by mistake would otherwise feed nothing, which looks exactly
90
+ * like a contract somebody deliberately kept internal.
91
+ */
92
+ private declaredApiTypes;
93
+ /**
94
+ * MCP membership has exactly ONE spelling, and this is the build-time half of enforcing it
95
+ * (`assertApiTypeMatchesMcpTools` in `@webpieces/core-util` is the wiring-time half).
96
+ *
97
+ * Declared in two places, the two can disagree — and the disagreement is invisible, because each
98
+ * declaration is individually valid. That is the defect this whole epic exists to remove, so it
99
+ * fails the DOCUMENT build rather than producing one that quietly lists the wrong tools.
100
+ */
101
+ private assertApiTypeMatchesMcpTools;
45
102
  /** The FIRST parameter's declared type. An endpoint with no parameter has no request document. */
46
103
  private requestOf;
47
104
  /** The declared return type, unwrapped from `Promise<...>` by the resolver. */
48
105
  private responseOf;
49
106
  /** `@WpAuthPublic()`, `@WpAuthJwt({...})`, … — recorded verbatim; this package rules on nothing. */
50
107
  private static authOf;
51
- /** `@WpMcpTool({ name, readOnly, ... })` — the name plus whichever hints were declared. */
108
+ /**
109
+ * `@WpAuthApiKey(regime, [{in: 'header', name: 'x-api-key', description: '…'}, …])`, parsed.
110
+ *
111
+ * A malformed declaration FAILS rather than yielding a half-parsed regime: the credentials are
112
+ * what a published document's security block is made of, and a document that silently omitted
113
+ * one would tell a partner they need fewer credentials than the running hook demands.
114
+ */
115
+ private static apiKeyOf;
116
+ /** ONE `{ in: …, name?: …, description?: … }` credential. */
117
+ private static credentialOf;
118
+ /**
119
+ * `@WpMcpTool({ name, openWorldHint })` — the two facts the source cannot otherwise state.
120
+ *
121
+ * `description` is NOT read: the method's JSDoc is the description for the agent and the partner
122
+ * alike. The three side-effect hints are not read either — they are computed from the endpoint's
123
+ * `operation`. See {@link DocumentedMcpTool} for why both of those are deliberate.
124
+ */
52
125
  private static mcpToolOf;
53
126
  /** `@MaskLog({ refreshToken: 'full' })` -> field name -> mask mode. */
54
127
  private static maskLogOf;
@@ -3,21 +3,77 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ApiDocExtractor = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const ts = tslib_1.__importStar(require("typescript"));
6
+ const core_util_1 = require("@webpieces/core-util");
6
7
  const ApiDocModel_1 = require("../model/ApiDocModel");
7
8
  const ApiDocExtractionError_1 = require("./ApiDocExtractionError");
8
9
  const ConstantFolder_1 = require("./ConstantFolder");
9
10
  const JsDoc_1 = require("./JsDoc");
10
11
  const SourceLocation_1 = require("./SourceLocation");
11
12
  const TypeResolver_1 = require("./TypeResolver");
12
- /** The decorator names this extractor reads, by NAME. It imports none of them — see the class doc. */
13
- const API_PATH = 'ApiPath';
14
- const ENDPOINT = 'Endpoint';
15
- const MASK_LOG = 'MaskLog';
16
- const MCP_TOOL = 'WpMcpTool';
17
- const MCP_AUTH = 'WpMcpAuthJwt';
13
+ /**
14
+ * The decorator names this extractor matches on, taken from the REAL SYMBOLS rather than re-typed as
15
+ * string literals.
16
+ *
17
+ * ## Why `.name` and not `'Endpoint'`
18
+ *
19
+ * The extractor matches decorators by NAME on the syntax — it must, because it reads a contract with
20
+ * the compiler and never executes it. The question is only where that name comes from, and a literal
21
+ * has a silent failure mode that a symbol does not: rename `@WpMcpTool` in `core-util` and a literal
22
+ * simply stops matching. The extractor then finds zero MCP tools, the MCP document is correctly not
23
+ * written because it has no tools in it, and the BUILD IS GREEN. A partner-facing document silently
24
+ * loses a section with nothing red anywhere (issue #1001).
25
+ *
26
+ * `Endpoint.name` makes that rename a COMPILE ERROR here, which in this repo is the delivery
27
+ * mechanism for a migration rather than an obstacle to one: a compile break names the new spelling
28
+ * and an agent applies it in one pass, where a green build teaches nobody anything.
29
+ *
30
+ * ## Why importing `@webpieces/core-util` is not the coupling it looks like
31
+ *
32
+ * Any contract carrying `@Endpoint` already depends on `core-util` by definition, so there is no
33
+ * upstream project this import could shut out. It is build-time only, it creates no cycle, and it
34
+ * costs a browser bundle nothing because nothing in a bundle imports this package. The direction that
35
+ * WOULD be fatal is `core-util` depending on the TypeScript compiler, and that is not this.
36
+ */
37
+ const API_PATH = core_util_1.ApiPath.name;
38
+ const ENDPOINT = core_util_1.Endpoint.name;
39
+ const MASK_LOG = core_util_1.MaskLog.name;
40
+ const MCP_TOOL = core_util_1.WpMcpTool.name;
41
+ const MCP_AUTH = core_util_1.WpMcpAuthJwt.name;
42
+ const API_KEY_AUTH = core_util_1.WpAuthApiKey.name;
43
+ const API_TYPE = core_util_1.ApiType.name;
44
+ /**
45
+ * The prefix shared by every credential decorator. A PREFIX genuinely has no symbol to take a name
46
+ * from, so it stays a literal — but the set it selects is pinned below, which is what stops it
47
+ * quietly matching nothing.
48
+ */
18
49
  const AUTH_PREFIX = 'WpAuth';
19
- /** The MCP tool hints a renderer surfaces to an agent. */
20
- const TOOL_HINTS = ['readOnly', 'destructive', 'idempotent', 'openWorld'];
50
+ /** Every credential decorator, by real symbol, so a rename of any of them fails to compile here. */
51
+ const AUTH_DECORATORS = new Set([
52
+ core_util_1.WpAuthPublic.name,
53
+ core_util_1.WpAuthJwt.name,
54
+ core_util_1.WpAuthOidc.name,
55
+ core_util_1.WpAuthSharedSecret.name,
56
+ core_util_1.WpAuthWebhook.name,
57
+ core_util_1.WpAuthApiKey.name,
58
+ core_util_1.WpAuthLocalOnly.name,
59
+ ]);
60
+ /** The REAL trigger kinds and side-effect contracts, so a contract cannot declare one that is not. */
61
+ const ENDPOINT_KINDS = [core_util_1.RPC, core_util_1.CLOUDTASKS, core_util_1.CRON, core_util_1.EXTERNAL];
62
+ const ENDPOINT_OPERATIONS = [core_util_1.READ, core_util_1.WRITE_IDEMPOTENT, core_util_1.WRITE];
63
+ const HTTP_METHODS = [core_util_1.GET, core_util_1.POST];
64
+ /** `@Endpoint`'s four required positions, named so a failure can say which one is missing. */
65
+ const ARGUMENT_NAMES = ['http method', 'path', 'operation', 'trigger kind'];
66
+ /** The allowed values of each required position, in the same order, for the same failure. */
67
+ const ARGUMENT_VALUES = [
68
+ HTTP_METHODS,
69
+ [],
70
+ ENDPOINT_OPERATIONS,
71
+ ENDPOINT_KINDS,
72
+ ];
73
+ /** The REAL document types, so a contract cannot name a document that does not exist. */
74
+ const API_TYPES = [core_util_1.SVC_TO_SVC, core_util_1.EXTERNAL_CUSTOMER, core_util_1.MCP];
75
+ /** The fail-closed default: a contract that declares nothing feeds only the internal document. */
76
+ const DEFAULT_API_TYPES = [core_util_1.SVC_TO_SVC];
21
77
  /**
22
78
  * ONE contract file -> ONE {@link ApiDocModel}. The single extraction pass both the OpenAPI documents
23
79
  * and the MCP tool list (#982) are rendered from.
@@ -30,13 +86,12 @@ const TOOL_HINTS = ['readOnly', 'destructive', 'idempotent', 'openWorld'];
30
86
  * made of, so the only place they exist is the source, and the only honest way to read the source is
31
87
  * the compiler.
32
88
  *
33
- * ## Why it imports NO webpieces package
89
+ * ## Why it IMPORTS `@webpieces/core-util`
34
90
  *
35
- * It depends on `typescript` and nothing else, so it can be pointed at ANY upstream project's
36
- * contract which is the whole point of shipping it as a package rather than as a script in this
37
- * repo. Decorators are therefore matched BY NAME on the syntax, never by importing the real
38
- * decorator. One `@webpieces/core-util` import here would make the package unusable by anybody whose
39
- * webpieces version differs from ours, which is everybody.
91
+ * Decorators are matched BY NAME on the syntax they must be, because this reads a contract and
92
+ * never executes it. The NAMES come from the real symbols (`Endpoint.name`), so a rename in
93
+ * `core-util` is a compile error here instead of a literal that quietly stops matching. The full
94
+ * argument, and why the import is not the coupling it looks like, is at those constants.
40
95
  *
41
96
  * ## What it does NOT do
42
97
  *
@@ -61,17 +116,54 @@ class ApiDocExtractor {
61
116
  }
62
117
  return this.extract(program, source);
63
118
  }
119
+ /**
120
+ * EVERY `@ApiPath` contract in one file, in declaration order.
121
+ *
122
+ * {@link extractFile} answers "what is THE contract in this file", which is the shape a manifest
123
+ * entry and a generated document have: one contract, one file. This answers a different question
124
+ * — "what does this file declare" — and it exists because a REPO does not obey that convention.
125
+ * `McpRemoteFixtures.ts` in `@webpieces/mcp-server` declares seven contracts, and the runtime
126
+ * registers MCP tools from all seven; a sweep that read only the first would report green while
127
+ * six contracts' worth of tools had never been looked at, which is the exact shape of silent miss
128
+ * this epic exists to remove.
129
+ *
130
+ * A file with no contract yields an EMPTY list rather than throwing: "this file has none" is an
131
+ * ordinary answer to this question, where it is a failure to answer {@link extractFile}'s.
132
+ */
133
+ extractAll(entryFile, compilerOptions = {}) {
134
+ const program = ts.createProgram([entryFile], compilerOptions);
135
+ const source = program.getSourceFile(entryFile);
136
+ if (source === undefined) {
137
+ throw new ApiDocExtractionError_1.ApiDocExtractionError('entry file is not part of the program', entryFile, 'Pass an absolute path to a .ts file that exists.');
138
+ }
139
+ return this.extractAllFrom(program, source);
140
+ }
141
+ /** {@link extractAll} against a program the caller already built. */
142
+ extractAllFrom(program, source) {
143
+ const models = [];
144
+ for (const statement of source.statements) {
145
+ if (ts.isClassDeclaration(statement) &&
146
+ ApiDocExtractor.decoratorCall(statement, API_PATH) !== undefined) {
147
+ models.push(this.extractContract(program, statement));
148
+ }
149
+ }
150
+ return models;
151
+ }
64
152
  /** The same extraction against a program the caller already built. */
65
153
  extract(program, source) {
154
+ return this.extractContract(program, this.findContract(source));
155
+ }
156
+ /** ONE contract class -> ONE model. The single place the walk actually happens. */
157
+ extractContract(program, contract) {
66
158
  const checker = program.getTypeChecker();
67
159
  const folder = new ConstantFolder_1.ConstantFolder(checker);
68
160
  const resolver = new TypeResolver_1.TypeResolver(checker);
69
- const contract = this.findContract(source);
70
161
  const pathDecorator = ApiDocExtractor.decoratorCall(contract, API_PATH);
71
162
  const basePathArgument = pathDecorator.arguments[0];
72
163
  const basePath = basePathArgument === undefined
73
164
  ? ''
74
165
  : folder.foldString(basePathArgument, '@ApiPath argument');
166
+ const apiTypes = this.declaredApiTypes(contract, folder);
75
167
  const endpoints = [];
76
168
  for (const member of contract.members) {
77
169
  const endpoint = this.endpointOf(member, folder, resolver);
@@ -79,7 +171,46 @@ class ApiDocExtractor {
79
171
  endpoints.push(endpoint);
80
172
  }
81
173
  }
82
- return new ApiDocModel_1.ApiDocModel(contract.name?.text ?? '<anonymous>', basePath, JsDoc_1.JsDoc.read(contract).description, endpoints, resolver.collectedTypes(), resolver.collectedUnmapped());
174
+ this.assertApiTypeMatchesMcpTools(contract, apiTypes, endpoints);
175
+ return new ApiDocModel_1.ApiDocModel(contract.name?.text ?? '<anonymous>', apiTypes, basePath, JsDoc_1.JsDoc.read(contract).description, endpoints, resolver.collectedTypes(), resolver.collectedUnmapped());
176
+ }
177
+ /**
178
+ * Extract ONE named type and everything it reaches, from a file that holds NO contract.
179
+ *
180
+ * This exists for a type nothing in a contract points at but a document still publishes — the
181
+ * document-wide error body a renderer's manifest names. Reading it with the SAME resolver is the
182
+ * point: a second reader would be a second answer to "what shape is this type", and the two
183
+ * would drift the first time a field changed.
184
+ *
185
+ * @throws ApiDocExtractionError when the file does not declare that name.
186
+ */
187
+ extractType(entryFile, typeName, compilerOptions = {}) {
188
+ const program = ts.createProgram([entryFile], compilerOptions);
189
+ const source = program.getSourceFile(entryFile);
190
+ if (source === undefined) {
191
+ throw new ApiDocExtractionError_1.ApiDocExtractionError('entry file is not part of the program', entryFile, 'Pass an absolute path to a .ts file that exists.');
192
+ }
193
+ const resolver = new TypeResolver_1.TypeResolver(program.getTypeChecker());
194
+ const declaration = ApiDocExtractor.declarationNamed(source, typeName);
195
+ if (declaration === undefined) {
196
+ throw new ApiDocExtractionError_1.ApiDocExtractionError(`no type named '${typeName}' is declared in this file`, entryFile, `Declare and export '${typeName}' there, or name the file that does.`);
197
+ }
198
+ resolver.resolveDeclaration(typeName, declaration, typeName);
199
+ return new ApiDocModel_1.ApiDocModel(typeName, DEFAULT_API_TYPES, '', '', [], resolver.collectedTypes(), resolver.collectedUnmapped());
200
+ }
201
+ /** The interface / class / type alias / enum declared under `name` at the file's top level. */
202
+ // webpieces-disable no-function-outside-class -- private static reader of this class
203
+ static declarationNamed(source, name) {
204
+ for (const statement of source.statements) {
205
+ const named = ts.isInterfaceDeclaration(statement) ||
206
+ ts.isClassDeclaration(statement) ||
207
+ ts.isTypeAliasDeclaration(statement) ||
208
+ ts.isEnumDeclaration(statement);
209
+ if (named && statement.name?.text === name) {
210
+ return statement;
211
+ }
212
+ }
213
+ return undefined;
83
214
  }
84
215
  /** The one `@ApiPath` class in the file. Zero is a hard failure; the FIRST wins if there are two. */
85
216
  findContract(source) {
@@ -101,20 +232,81 @@ class ApiDocExtractor {
101
232
  return undefined;
102
233
  }
103
234
  const methodName = member.name.text;
104
- const pathArgument = call.arguments[0];
105
- if (pathArgument === undefined) {
106
- throw new ApiDocExtractionError_1.ApiDocExtractionError(`@Endpoint on '${methodName}' declares no path`, SourceLocation_1.SourceLocation.of(call), "Give it a path: @Endpoint('/thing', 'rpc').");
107
- }
108
- const path = folder.foldString(pathArgument, `@Endpoint path on '${methodName}'`);
109
- const kindArgument = call.arguments[1];
110
- if (kindArgument === undefined) {
111
- throw new ApiDocExtractionError_1.ApiDocExtractionError(`@Endpoint on '${methodName}' declares no kind`, SourceLocation_1.SourceLocation.of(call), "State what triggers it: 'rpc', 'cloudtasks', 'cron' or 'external'.");
112
- }
113
- const kind = folder.foldString(kindArgument, `@Endpoint kind on '${methodName}'`);
114
- const options = call.arguments[2];
235
+ // `@Endpoint(httpMethod, path, operation, kind, options?)` — METHOD-FIRST, five positions.
236
+ // The positions are read here and nowhere else, so the one place that has to change when the
237
+ // decorator changes is this block plus `ARGUMENT_NAMES` beside it.
238
+ const httpMethod = this.requiredArgument(call, methodName, 0, folder);
239
+ const path = this.requiredArgument(call, methodName, 1, folder);
240
+ const operation = this.requiredArgument(call, methodName, 2, folder);
241
+ const kind = this.requiredArgument(call, methodName, 3, folder);
242
+ const options = call.arguments[4];
115
243
  const literal = options !== undefined && ts.isObjectLiteralExpression(options) ? options : undefined;
116
244
  const doc = JsDoc_1.JsDoc.read(member);
117
- return new ApiDocModel_1.DocumentedEndpoint(methodName, path, kind, ApiDocExtractor.booleanProperty(literal, 'hidden'), new ApiDocModel_1.DocumentedEndpointOptions(ApiDocExtractor.booleanProperty(literal, 'formPost'), ApiDocExtractor.stringProperty(literal, 'calledBy', folder), ApiDocExtractor.stringProperty(literal, 'callerKind', folder)), ApiDocExtractor.authOf(member), ApiDocExtractor.mcpToolOf(member, folder), ApiDocExtractor.decoratorCall(member, MCP_AUTH)?.arguments[0]?.getText(), ApiDocExtractor.maskLogOf(member), doc.description, doc.mcp, this.requestOf(member, methodName, resolver), this.responseOf(member, methodName, resolver));
245
+ const mcpTool = ApiDocExtractor.mcpToolOf(member, folder);
246
+ return new ApiDocModel_1.DocumentedEndpoint(methodName, httpMethod, path, operation, kind, ApiDocExtractor.booleanProperty(literal, 'hidden'), ApiDocExtractor.booleanProperty(literal, 'openWorld'), new ApiDocModel_1.DocumentedEndpointOptions(ApiDocExtractor.booleanProperty(literal, 'formPost'), ApiDocExtractor.stringProperty(literal, 'calledBy', folder), ApiDocExtractor.stringProperty(literal, 'callerKind', folder)), ApiDocExtractor.authOf(member, folder), mcpTool, ApiDocExtractor.decoratorCall(member, MCP_AUTH)?.arguments[0]?.getText(), ApiDocExtractor.maskLogOf(member), doc.description, doc.mcp, this.requestOf(member, methodName, resolver), this.responseOf(member, methodName, resolver));
247
+ }
248
+ /**
249
+ * One REQUIRED positional argument of `@Endpoint`, folded to the string it denotes.
250
+ *
251
+ * Every one of the four is an enum member (`POST`, `READ`, `RPC`) as often as it is a literal,
252
+ * and the folder resolves both — a document that printed `RPC` where the trigger goes would be
253
+ * worse than no document. A missing one is a hard failure naming the position, because the
254
+ * alternative is a document quietly missing a verb.
255
+ */
256
+ requiredArgument(call, methodName, index, folder) {
257
+ const argument = call.arguments[index];
258
+ const what = ARGUMENT_NAMES[index];
259
+ if (argument === undefined) {
260
+ throw new ApiDocExtractionError_1.ApiDocExtractionError(`@Endpoint on '${methodName}' declares no ${what}`, SourceLocation_1.SourceLocation.of(call), "Write all four: @Endpoint(POST, '/thing', READ, RPC).");
261
+ }
262
+ const value = folder.foldString(argument, `@Endpoint ${what} on '${methodName}'`);
263
+ const allowed = ARGUMENT_VALUES[index];
264
+ if (allowed.length > 0 && !allowed.includes(value)) {
265
+ throw new ApiDocExtractionError_1.ApiDocExtractionError(`@Endpoint on '${methodName}' declares ${what} '${value}', which is not one of ` +
266
+ allowed.join(', '), SourceLocation_1.SourceLocation.of(argument), `Use one of the exported constants: ${allowed.join(', ')}.`);
267
+ }
268
+ return value;
269
+ }
270
+ /**
271
+ * `@ApiType(...)` on the contract, folded to the real values.
272
+ *
273
+ * A value that is not one of the three documents is a HARD FAILURE rather than a silent drop: a
274
+ * contract that named `CUSTOMER` by mistake would otherwise feed nothing, which looks exactly
275
+ * like a contract somebody deliberately kept internal.
276
+ */
277
+ declaredApiTypes(node, folder) {
278
+ const call = ApiDocExtractor.decoratorCall(node, API_TYPE);
279
+ if (call === undefined) {
280
+ return DEFAULT_API_TYPES;
281
+ }
282
+ const declared = call.arguments.map((argument) => folder.foldString(argument, `@${API_TYPE} argument`));
283
+ for (const apiType of declared) {
284
+ if (!API_TYPES.includes(apiType)) {
285
+ throw new ApiDocExtractionError_1.ApiDocExtractionError(`@${API_TYPE} names '${apiType}', which is not a generated document`, SourceLocation_1.SourceLocation.of(call), `Use one of the exported constants: ${API_TYPES.join(', ')}.`);
286
+ }
287
+ }
288
+ return declared.length === 0 ? DEFAULT_API_TYPES : declared;
289
+ }
290
+ /**
291
+ * MCP membership has exactly ONE spelling, and this is the build-time half of enforcing it
292
+ * (`assertApiTypeMatchesMcpTools` in `@webpieces/core-util` is the wiring-time half).
293
+ *
294
+ * Declared in two places, the two can disagree — and the disagreement is invisible, because each
295
+ * declaration is individually valid. That is the defect this whole epic exists to remove, so it
296
+ * fails the DOCUMENT build rather than producing one that quietly lists the wrong tools.
297
+ */
298
+ assertApiTypeMatchesMcpTools(contract, apiTypes, endpoints) {
299
+ const tools = endpoints.filter((e) => e.mcpTool !== undefined);
300
+ const declaresMcp = apiTypes.includes(core_util_1.MCP);
301
+ if (declaresMcp && tools.length === 0) {
302
+ throw new ApiDocExtractionError_1.ApiDocExtractionError(`@${API_TYPE} names MCP but no method carries @${MCP_TOOL}`, SourceLocation_1.SourceLocation.of(contract), `Add @${MCP_TOOL}({name, description, openWorldHint}) to the methods agents may ` +
303
+ `call, or drop MCP from the @${API_TYPE} list.`);
304
+ }
305
+ if (!declaresMcp && tools.length > 0) {
306
+ const named = tools.map((e) => e.methodName).join(', ');
307
+ throw new ApiDocExtractionError_1.ApiDocExtractionError(`@${MCP_TOOL} is on ${named} but @${API_TYPE} does not name MCP`, SourceLocation_1.SourceLocation.of(contract), `Add MCP to the @${API_TYPE} list — membership has ONE spelling, so a tool on a ` +
308
+ 'contract nobody published to agents is a contradiction, not a hint.');
309
+ }
118
310
  }
119
311
  /** The FIRST parameter's declared type. An endpoint with no parameter has no request document. */
120
312
  requestOf(member, methodName, resolver) {
@@ -133,7 +325,7 @@ class ApiDocExtractor {
133
325
  }
134
326
  /** `@WpAuthPublic()`, `@WpAuthJwt({...})`, … — recorded verbatim; this package rules on nothing. */
135
327
  // webpieces-disable no-function-outside-class -- private static reader of this class
136
- static authOf(member) {
328
+ static authOf(member, folder) {
137
329
  const decorators = ts.canHaveDecorators(member) ? (ts.getDecorators(member) ?? []) : [];
138
330
  for (const decorator of decorators) {
139
331
  const call = decorator.expression;
@@ -141,13 +333,60 @@ class ApiDocExtractor {
141
333
  continue;
142
334
  }
143
335
  const name = call.expression.text;
144
- if (name.startsWith(AUTH_PREFIX) && name !== MCP_AUTH) {
145
- return new ApiDocModel_1.DocumentedAuth(name, call.arguments[0]?.getText());
336
+ if (name.startsWith(AUTH_PREFIX) && AUTH_DECORATORS.has(name)) {
337
+ return new ApiDocModel_1.DocumentedAuth(name, call.arguments.map((argument) => argument.getText()), name === API_KEY_AUTH ? ApiDocExtractor.apiKeyOf(call, folder) : undefined);
146
338
  }
147
339
  }
148
340
  return undefined;
149
341
  }
150
- /** `@WpMcpTool({ name, readOnly, ... })` — the name plus whichever hints were declared. */
342
+ /**
343
+ * `@WpAuthApiKey(regime, [{in: 'header', name: 'x-api-key', description: '…'}, …])`, parsed.
344
+ *
345
+ * A malformed declaration FAILS rather than yielding a half-parsed regime: the credentials are
346
+ * what a published document's security block is made of, and a document that silently omitted
347
+ * one would tell a partner they need fewer credentials than the running hook demands.
348
+ */
349
+ // webpieces-disable no-function-outside-class -- private static reader of this class
350
+ static apiKeyOf(call, folder) {
351
+ const regimeArgument = call.arguments[0];
352
+ const credentialsArgument = call.arguments[1];
353
+ if (regimeArgument === undefined || credentialsArgument === undefined) {
354
+ throw new ApiDocExtractionError_1.ApiDocExtractionError('@WpAuthApiKey needs a regime AND its credentials', SourceLocation_1.SourceLocation.of(call), "Write both: @WpAuthApiKey('partner', [{ in: 'header', name: 'x-api-key' }]).");
355
+ }
356
+ const regime = folder.foldString(regimeArgument, '@WpAuthApiKey regime');
357
+ // FOLLOW a name first: a credential list shared by every method of a contract is written
358
+ // once as a `const` and named per method, which is better source than a copy per method.
359
+ const credentialsLiteral = folder.follow(credentialsArgument);
360
+ if (!ts.isArrayLiteralExpression(credentialsLiteral)) {
361
+ throw new ApiDocExtractionError_1.ApiDocExtractionError('@WpAuthApiKey credentials is not an array literal', SourceLocation_1.SourceLocation.of(credentialsArgument), 'Write the credentials as an array literal, inline or in a `const`; a value ' +
362
+ 'assembled at runtime cannot appear in a published security scheme.');
363
+ }
364
+ const credentials = [];
365
+ for (const element of credentialsLiteral.elements) {
366
+ credentials.push(ApiDocExtractor.credentialOf(element, folder));
367
+ }
368
+ return new ApiDocModel_1.DocumentedApiKey(regime, credentials);
369
+ }
370
+ /** ONE `{ in: …, name?: …, description?: … }` credential. */
371
+ // webpieces-disable no-function-outside-class -- private static reader of this class
372
+ static credentialOf(expression, folder) {
373
+ const element = folder.follow(expression);
374
+ if (!ts.isObjectLiteralExpression(element)) {
375
+ throw new ApiDocExtractionError_1.ApiDocExtractionError('an @WpAuthApiKey credential is not an object literal', SourceLocation_1.SourceLocation.of(element), "Write it inline: { in: 'header', name: 'x-api-key' }.");
376
+ }
377
+ const location = ApiDocExtractor.stringProperty(element, 'in', folder);
378
+ if (location === undefined) {
379
+ throw new ApiDocExtractionError_1.ApiDocExtractionError('an @WpAuthApiKey credential declares no `in`', SourceLocation_1.SourceLocation.of(element), "Say where it rides: `in: 'header'` with a name, or `in: 'bearer'`.");
380
+ }
381
+ return new ApiDocModel_1.DocumentedApiKeyCredential(location, ApiDocExtractor.stringProperty(element, 'name', folder), ApiDocExtractor.stringProperty(element, 'description', folder));
382
+ }
383
+ /**
384
+ * `@WpMcpTool({ name, openWorldHint })` — the two facts the source cannot otherwise state.
385
+ *
386
+ * `description` is NOT read: the method's JSDoc is the description for the agent and the partner
387
+ * alike. The three side-effect hints are not read either — they are computed from the endpoint's
388
+ * `operation`. See {@link DocumentedMcpTool} for why both of those are deliberate.
389
+ */
151
390
  // webpieces-disable no-function-outside-class -- private static reader of this class
152
391
  static mcpToolOf(member, folder) {
153
392
  const call = ApiDocExtractor.decoratorCall(member, MCP_TOOL);
@@ -155,14 +394,7 @@ class ApiDocExtractor {
155
394
  if (argument === undefined || !ts.isObjectLiteralExpression(argument)) {
156
395
  return undefined;
157
396
  }
158
- const hints = new Map();
159
- for (const hint of TOOL_HINTS) {
160
- const value = ApiDocExtractor.findProperty(argument, hint);
161
- if (value !== undefined) {
162
- hints.set(hint, value.kind === ts.SyntaxKind.TrueKeyword);
163
- }
164
- }
165
- return new ApiDocModel_1.DocumentedMcpTool(ApiDocExtractor.stringProperty(argument, 'name', folder) ?? '', hints);
397
+ return new ApiDocModel_1.DocumentedMcpTool(ApiDocExtractor.stringProperty(argument, 'name', folder) ?? '');
166
398
  }
167
399
  /** `@MaskLog({ refreshToken: 'full' })` -> field name -> mask mode. */
168
400
  // webpieces-disable no-function-outside-class -- private static reader of this class