@webpieces/nx-webpieces-rules 0.4.808 → 0.4.809

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.
@@ -0,0 +1,361 @@
1
+ "use strict";
2
+ /**
3
+ * `api-rules-for-openapi` and `api-rules-for-mcp` (#1011) — the CI half of "is this contract
4
+ * publishable", on EVERY `@ApiPath` class in the workspace, `@ApiType` or not.
5
+ *
6
+ * ## The acceptance contract, and why this file drives the generator instead of copying it
7
+ *
8
+ * The rules exist so that ADDING `@ApiType(...)` (and `@WpMcpTool`) to a contract that passes them
9
+ * always works. That is only true if "expressible" has exactly ONE definition, so this scan runs the
10
+ * generator's own code — `ApiDocExtractor` / `TypeResolver` from `@webpieces/api-doc-model` for the
11
+ * OpenAPI half, and `McpSchemaRenderer` tool-by-tool for the MCP half. A second implementation of
12
+ * "what can be published" would drift from the first on the release that improved either one, and
13
+ * the drift would be silent: the rules would stay green while generation started failing. The two
14
+ * packages ship on the same release train, so the dependency is in lockstep by construction.
15
+ *
16
+ * Two things here are STRICTER than the generator, deliberately, and both are publishing rules
17
+ * rather than expressibility ones (being stricter cannot break the acceptance contract — it can only
18
+ * refuse something that would have generated):
19
+ *
20
+ * - an `unknown` VALUE TYPE anywhere (`Record<string, unknown>`, `unknown[]`, a bare `unknown`
21
+ * field). The extractor maps it to a primitive and the generator publishes `{}`, which in JSON
22
+ * Schema means "anything" — a partner-facing field with no shape, which is the defect the
23
+ * unmapped guard exists for, arriving through a door the guard does not watch.
24
+ * - an RPC whose response is `void`. Fire-and-forget is the CONTRACT of a `cloudtasks` or `cron`
25
+ * endpoint and is allowed there; an RPC that answers nothing can never gain a field without a
26
+ * breaking change, where a named empty response object grows additively forever.
27
+ *
28
+ * ## Why it lives in the rules engine and not in the doc parser
29
+ *
30
+ * `@webpieces/api-doc-model` is only ever pointed at contracts somebody chose to publish. `@ApiType`
31
+ * is a PUBLISHING decision added later, on purpose — so a shape rule that only ran on contracts which
32
+ * had already opted in would let a team discover, six months afterwards, that the type was never
33
+ * expressible, by which time it is in partners' generated clients. Every check below runs on every
34
+ * contract in the workspace.
35
+ *
36
+ * ## Root-level unions are NOT re-checked here
37
+ *
38
+ * `no-root-union-api-type` (#1009) already refuses them, workspace-wide, with its own config key and
39
+ * its own per-site hatch. One implementation. The MCP half still reports one when it meets it,
40
+ * because `McpSchemaRenderer` refuses it as its own backstop and this scan reports whatever the
41
+ * renderer says — which is the correct division: the OpenAPI document publishes a root union
42
+ * perfectly well, and only a tool schema cannot carry one.
43
+ */
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.ApiDocRulesScan = void 0;
46
+ const tslib_1 = require("tslib");
47
+ const fs = tslib_1.__importStar(require("fs"));
48
+ const path = tslib_1.__importStar(require("path"));
49
+ const ts = tslib_1.__importStar(require("typescript"));
50
+ const api_doc_model_1 = require("@webpieces/api-doc-model");
51
+ const rules_config_1 = require("@webpieces/rules-config");
52
+ const api_ast_1 = require("./api-ast");
53
+ const api_doc_rules_1 = require("./api-doc-rules");
54
+ const api_doc_rules_verdicts_1 = require("./api-doc-rules-verdicts");
55
+ /** `@ApiPath(` at COLUMN ZERO — a docstring that TALKS about a contract declares none. */
56
+ const DECLARES_CONTRACT = /^@ApiPath\(/m;
57
+ /** ONE contract file, and which of the two rules apply to the project that owns it. */
58
+ class ContractFile {
59
+ absPath;
60
+ openApi;
61
+ mcp;
62
+ constructor(absPath, openApi, mcp) {
63
+ this.absPath = absPath;
64
+ this.openApi = openApi;
65
+ this.mcp = mcp;
66
+ }
67
+ }
68
+ /** Where one declaration sits, already split out of the extractor's `File.ts:LINE:COL` spelling. */
69
+ class Site {
70
+ absPath;
71
+ line;
72
+ constructor(absPath, line) {
73
+ this.absPath = absPath;
74
+ this.line = line;
75
+ }
76
+ /** `path/to/File.ts:LINE`, workspace-relative — what a refusal prints. */
77
+ relativeTo(workspaceRoot) {
78
+ return `${path.relative(workspaceRoot, this.absPath)}:${this.line}`;
79
+ }
80
+ /** `abs/File.ts:12:5` -> a Site. An unparseable one falls back to line 1 of `fallback`. */
81
+ // webpieces-disable no-function-outside-class -- static factory of this class
82
+ static parse(location, fallback) {
83
+ const match = location.match(/^(.*):(\d+):\d+$/);
84
+ if (match === null)
85
+ return new Site(fallback, 1);
86
+ return new Site(match[1], Number(match[2]));
87
+ }
88
+ }
89
+ /**
90
+ * ONE rule's accumulator. It is what applies the per-site disable, so the disable semantics live in
91
+ * exactly one place and cannot differ between the two rules.
92
+ */
93
+ class DefectCollector {
94
+ ruleName;
95
+ violations = [];
96
+ reasonless = [];
97
+ constructor(ruleName) {
98
+ this.ruleName = ruleName;
99
+ }
100
+ /** The rule this collector reports under — what a shared defect's cure has to name. */
101
+ rule() {
102
+ return this.ruleName;
103
+ }
104
+ add(defect, site) {
105
+ const disable = api_doc_rules_1.DisableComment.readAt(site.absPath, site.line, this.ruleName);
106
+ if (disable === undefined) {
107
+ this.violations.push(defect);
108
+ return;
109
+ }
110
+ if (!disable.hasReason)
111
+ this.reasonless.push(defect);
112
+ }
113
+ findings() {
114
+ return new api_doc_rules_1.ApiRuleFindings(DefectCollector.externalFirst(this.violations), DefectCollector.externalFirst(this.reasonless));
115
+ }
116
+ /**
117
+ * Partner-facing contracts first. The same defect is a different size depending on who reads the
118
+ * document it would have been in, and a list that buries the `external-customer` ones among
119
+ * thirty internal ones has hidden the only urgent line in it.
120
+ */
121
+ // webpieces-disable no-function-outside-class -- private static ordering of this class
122
+ static externalFirst(found) {
123
+ return [...found].sort((a, b) => Number(b.isExternal()) - Number(a.isExternal()));
124
+ }
125
+ }
126
+ /**
127
+ * Routes a defect to the rule that owns it.
128
+ *
129
+ * Every OpenAPI-level defect ALSO blocks MCP, so it is reported by whichever rule is running —
130
+ * `api-rules-for-openapi` when that one is on, and `api-rules-for-mcp` alone when it is not. It is
131
+ * never reported twice: a team running both would otherwise read every shared defect in two places
132
+ * and have to work out that they are one.
133
+ */
134
+ class DefectSink {
135
+ openApi;
136
+ mcp;
137
+ constructor(openApi, mcp) {
138
+ this.openApi = openApi;
139
+ this.mcp = mcp;
140
+ }
141
+ /**
142
+ * A defect that blocks the OpenAPI document, and therefore every tool on it too.
143
+ *
144
+ * The defect is BUILT from the rule that ends up reporting it, not handed in ready-made, because
145
+ * a shared defect does not know in advance which rule will carry it: `api-rules-for-openapi` when
146
+ * that one runs, and `api-rules-for-mcp` alone when it does not. A cure that named a fixed rule
147
+ * would, on the mcp-only configuration, prescribe a `// webpieces-disable` line the collector
148
+ * reading that site does not look for.
149
+ */
150
+ shared(build, site) {
151
+ const target = this.openApi ?? this.mcp;
152
+ if (target === undefined)
153
+ return;
154
+ target.add(build(target.rule()), site);
155
+ }
156
+ /** A defect that blocks ONE tool and nothing else. */
157
+ mcpOnly(defect, site) {
158
+ this.mcp?.add(defect, site);
159
+ }
160
+ anyRuleRuns() {
161
+ return this.openApi !== undefined || this.mcp !== undefined;
162
+ }
163
+ /** True when `api-rules-for-mcp` applies to this file — what the exclusion list is scoped to. */
164
+ mcpRuns() {
165
+ return this.mcp !== undefined;
166
+ }
167
+ }
168
+ /**
169
+ * Walks every project's `src`, extracts every `@ApiPath` contract with the generator's own
170
+ * extractor, and judges the result against the two rules.
171
+ *
172
+ * ONE `ts.Program` over every contract file in the workspace, because a DTO a contract reaches
173
+ * routinely lives in another project and the checker has to be able to follow the import — the same
174
+ * reason the repo sweep in `@webpieces/api-doc-model`'s own spec builds one program rather than one
175
+ * per file.
176
+ */
177
+ class ApiDocRulesScan {
178
+ workspaceRoot;
179
+ projectInfos;
180
+ openApiRule;
181
+ mcpRule;
182
+ /**
183
+ * Every `@InvalidEndpointForMcp` endpoint met on a file the MCP rule applies to.
184
+ *
185
+ * Collected even on a run with no findings at all, because restating them IS the feature: the
186
+ * alternative considered in #1014 was a one-off warning when somebody adds one, and a warning
187
+ * printed once at the moment of the decision is read by the one person who already knows.
188
+ */
189
+ exclusions = [];
190
+ constructor(workspaceRoot, projectInfos,
191
+ /** OFF unless a caller read otherwise out of webpieces.config.json — see `defaultRules`. */
192
+ openApiRule = api_doc_rules_1.ApiDocRule.off(api_doc_rules_1.OPENAPI_RULE), mcpRule = api_doc_rules_1.ApiDocRule.off(api_doc_rules_1.MCP_RULE)) {
193
+ this.workspaceRoot = workspaceRoot;
194
+ this.projectInfos = projectInfos;
195
+ this.openApiRule = openApiRule;
196
+ this.mcpRule = mcpRule;
197
+ }
198
+ run() {
199
+ if (!this.openApiRule.enabled && !this.mcpRule.enabled)
200
+ return api_doc_rules_1.ApiDocRulesFindings.empty();
201
+ const files = this.contractFiles();
202
+ if (files.length === 0)
203
+ return api_doc_rules_1.ApiDocRulesFindings.empty();
204
+ const openApi = this.openApiRule.enabled ? new DefectCollector(api_doc_rules_1.OPENAPI_RULE) : undefined;
205
+ const mcp = this.mcpRule.enabled ? new DefectCollector(api_doc_rules_1.MCP_RULE) : undefined;
206
+ const program = ts.createProgram(files.map((file) => file.absPath), this.compilerOptions());
207
+ const toolNames = new Map();
208
+ for (const file of files) {
209
+ const sink = new DefectSink(file.openApi ? openApi : undefined, file.mcp ? mcp : undefined);
210
+ this.judgeFile(file, program, sink, toolNames);
211
+ }
212
+ return new api_doc_rules_1.ApiDocRulesFindings(openApi?.findings() ?? new api_doc_rules_1.ApiRuleFindings([], []), mcp?.findings() ?? new api_doc_rules_1.ApiRuleFindings([], []), this.exclusions);
213
+ }
214
+ /** Every contract in one file, or the ONE refusal that stopped the file being read at all. */
215
+ judgeFile(file, program, sink, toolNames) {
216
+ if (!sink.anyRuleRuns())
217
+ return;
218
+ const source = program.getSourceFile(file.absPath);
219
+ if (source === undefined)
220
+ return;
221
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- an extraction refusal IS a finding; it is reported, not propagated
222
+ try {
223
+ for (const model of new api_doc_model_1.ApiDocExtractor().extractAllFrom(program, source)) {
224
+ this.judgeModel(model, source, sink, toolNames);
225
+ }
226
+ }
227
+ catch (err) {
228
+ //const error = toError(err);
229
+ if (!(err instanceof api_doc_model_1.ApiDocExtractionError))
230
+ throw err;
231
+ this.reportExtractionFailure(err, file, source, sink);
232
+ }
233
+ }
234
+ /**
235
+ * An extraction that REFUSED. Reported under the shared list because it stops BOTH documents:
236
+ * `@Endpoint` arguments that cannot be constant-folded, a bound on a non-numeric field, and the
237
+ * `@ApiType(..., MCP)` ⇔ `@WpMcpTool` biconditional all fail here, before a model exists.
238
+ */
239
+ reportExtractionFailure(error, file, source, sink) {
240
+ const site = Site.parse(error.location, file.absPath);
241
+ sink.shared(() => new api_doc_rules_1.ApiContractDefect((0, api_doc_rules_verdicts_1.contractNamesIn)(source)[0] ?? path.basename(file.absPath), '', `the contract cannot be read at all — ${error.message}`, site.relativeTo(this.workspaceRoot), error.cure, []), site);
242
+ }
243
+ /** ONE contract: the shared OpenAPI checks, then the MCP-only ones. */
244
+ judgeModel(model, source, sink, toolNames) {
245
+ const lines = (0, api_doc_rules_verdicts_1.contractLinesOf)(source, model.contractName);
246
+ this.judgeUnmapped(model, sink, source.fileName);
247
+ this.judgeUnknownValues(model, sink, source.fileName);
248
+ this.judgeRpcResponses(model, source, lines, sink);
249
+ this.judgeTools(model, source, lines, sink, toolNames);
250
+ }
251
+ /** Everything `TypeResolver` could not represent — the generator's OWN verdict, re-worded. */
252
+ judgeUnmapped(model, sink, fallback) {
253
+ for (const unmapped of model.unmapped) {
254
+ const site = Site.parse(unmapped.location, fallback);
255
+ const verdict = (0, api_doc_rules_verdicts_1.classifyUnmapped)(unmapped);
256
+ sink.shared(() => new api_doc_rules_1.ApiContractDefect(model.contractName, '', verdict.what, site.relativeTo(this.workspaceRoot), verdict.cure, model.apiTypes), site);
257
+ }
258
+ }
259
+ /** `Record<string, unknown>`, `unknown[]`, `x: unknown` — by VALUE TYPE, never by spelling. */
260
+ judgeUnknownValues(model, sink, fallback) {
261
+ for (const type of model.types.values()) {
262
+ for (const field of type.fields) {
263
+ if (!(0, api_doc_rules_verdicts_1.carriesUnknown)(field.type))
264
+ continue;
265
+ const site = Site.parse(field.location, fallback);
266
+ sink.shared((rule) => new api_doc_rules_1.ApiContractDefect(model.contractName, '', `'${type.name}.${field.name}' publishes an 'unknown' value, so the ` +
267
+ 'document states no shape for it at all', site.relativeTo(this.workspaceRoot), (0, api_doc_rules_verdicts_1.unknownValueCure)(rule), model.apiTypes), site);
268
+ }
269
+ }
270
+ }
271
+ /**
272
+ * An RPC must NAME a response DTO, even an empty one.
273
+ *
274
+ * `Promise<void>` on a `cloudtasks` or `cron` endpoint is the CONTRACT — fire-and-forget, nothing
275
+ * to shape — and is allowed. On an RPC it is a one-way door: a `void` response can never gain a
276
+ * field without breaking every generated client, where `{}` grows additively forever. This is a
277
+ * contract-EVOLUTION rule, which is why it is here and not in the MCP half: it is worth having on
278
+ * an RPC that never becomes a tool.
279
+ */
280
+ judgeRpcResponses(model, source, lines, sink) {
281
+ for (const endpoint of model.endpoints) {
282
+ if (endpoint.kind !== api_doc_rules_verdicts_1.RPC_KIND)
283
+ continue;
284
+ if (endpoint.response !== undefined && !(0, api_doc_rules_verdicts_1.isVoidLike)(endpoint.response))
285
+ continue;
286
+ const site = new Site(source.fileName, lines.lineOf(endpoint.methodName));
287
+ sink.shared(() => new api_doc_rules_1.ApiContractDefect(model.contractName, endpoint.methodName, 'an RPC returns nothing a document can name (void, unknown, or no declared ' +
288
+ 'return type)', site.relativeTo(this.workspaceRoot), api_doc_rules_verdicts_1.VOID_RPC_CURE, model.apiTypes), site);
289
+ }
290
+ }
291
+ /** Everything `McpToolRegistry` refuses to boot on, plus whatever the renderer cannot render. */
292
+ judgeTools(model, source, lines, sink, toolNames) {
293
+ const renderer = new api_doc_model_1.McpSchemaRenderer(model);
294
+ for (const endpoint of model.endpoints) {
295
+ if (endpoint.invalidForMcp !== undefined) {
296
+ if (!sink.mcpRuns())
297
+ continue;
298
+ // @InvalidEndpointForMcp IS the answer to "could this be a tool". The decorator
299
+ // carries the argument, so the rule asks nothing further and no webpieces-disable is
300
+ // needed — a suppression would be a second, weaker spelling of the same declaration.
301
+ this.exclusions.push(new api_doc_rules_1.McpExclusion(model.contractName, endpoint.methodName, endpoint.invalidForMcp, new Site(source.fileName, lines.lineOf(endpoint.methodName)).relativeTo(this.workspaceRoot)));
302
+ continue;
303
+ }
304
+ if (endpoint.mcpTool === undefined)
305
+ continue;
306
+ const site = new Site(source.fileName, lines.lineOf(endpoint.methodName));
307
+ for (const failure of (0, api_doc_rules_verdicts_1.toolFailures)(endpoint, renderer, toolNames, model)) {
308
+ sink.mcpOnly(new api_doc_rules_1.ApiContractDefect(model.contractName, endpoint.methodName, failure.what, site.relativeTo(this.workspaceRoot), failure.cure, model.apiTypes), site);
309
+ }
310
+ }
311
+ for (const methodName of lines.toolsWithoutEndpoint) {
312
+ const site = new Site(source.fileName, lines.lineOf(methodName));
313
+ sink.mcpOnly(new api_doc_rules_1.ApiContractDefect(model.contractName, methodName, 'carries @WpMcpTool but is not an @Endpoint, so it is not routed at all', site.relativeTo(this.workspaceRoot), "Add @Endpoint(POST, '/path', READ, RPC) to it, or drop the @WpMcpTool.", model.apiTypes), site);
314
+ }
315
+ }
316
+ /** Every non-test `.ts` under a project's `src` whose text DECLARES an `@ApiPath` contract. */
317
+ contractFiles() {
318
+ const found = [];
319
+ for (const info of this.projectInfos.values()) {
320
+ if (info.root === '' || info.root === '.')
321
+ continue;
322
+ const openApi = this.openApiRule.enabled &&
323
+ !(0, rules_config_1.matchesAnyGlob)(info.root, this.openApiRule.allowedPaths);
324
+ const mcp = this.mcpRule.enabled && !(0, rules_config_1.matchesAnyGlob)(info.root, this.mcpRule.allowedPaths);
325
+ if (!openApi && !mcp)
326
+ continue;
327
+ const srcDir = path.join(path.resolve(this.workspaceRoot, info.root), 'src');
328
+ if (!fs.existsSync(srcDir))
329
+ continue;
330
+ for (const file of (0, api_ast_1.collectTsFiles)(srcDir)) {
331
+ if ((0, api_ast_1.isTestFile)(file))
332
+ continue; // a fixture is not a published contract
333
+ if (!DECLARES_CONTRACT.test(fs.readFileSync(file, 'utf8')))
334
+ continue;
335
+ found.push(new ContractFile(file, openApi, mcp));
336
+ }
337
+ }
338
+ return found.sort((a, b) => a.absPath.localeCompare(b.absPath));
339
+ }
340
+ /**
341
+ * `tsconfig.base.json`'s options when the workspace has one, so an `@webpieces/*` import in a
342
+ * contract RESOLVES and the checker can follow a DTO into another project. Without that the
343
+ * resolver reports every cross-project type as unmapped, which would be a rule failing on its
344
+ * own inability to read rather than on anything the author wrote.
345
+ */
346
+ compilerOptions() {
347
+ const base = path.join(this.workspaceRoot, 'tsconfig.base.json');
348
+ const declared = fs.existsSync(base)
349
+ ? ts.parseJsonConfigFileContent(ts.readConfigFile(base, ts.sys.readFile).config, ts.sys, this.workspaceRoot).options
350
+ : {};
351
+ return {
352
+ ...declared,
353
+ noEmit: true,
354
+ skipLibCheck: true,
355
+ types: [],
356
+ experimentalDecorators: true,
357
+ };
358
+ }
359
+ }
360
+ exports.ApiDocRulesScan = ApiDocRulesScan;
361
+ //# sourceMappingURL=api-doc-rules-scan.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-doc-rules-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/nx-webpieces-rules/src/lib/api-usage/api-doc-rules-scan.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `api-rules-for-openapi` and `api-rules-for-mcp` (#1011) — the CI half of \"is this contract\n * publishable\", on EVERY `@ApiPath` class in the workspace, `@ApiType` or not.\n *\n * ## The acceptance contract, and why this file drives the generator instead of copying it\n *\n * The rules exist so that ADDING `@ApiType(...)` (and `@WpMcpTool`) to a contract that passes them\n * always works. That is only true if \"expressible\" has exactly ONE definition, so this scan runs the\n * generator's own code — `ApiDocExtractor` / `TypeResolver` from `@webpieces/api-doc-model` for the\n * OpenAPI half, and `McpSchemaRenderer` tool-by-tool for the MCP half. A second implementation of\n * \"what can be published\" would drift from the first on the release that improved either one, and\n * the drift would be silent: the rules would stay green while generation started failing. The two\n * packages ship on the same release train, so the dependency is in lockstep by construction.\n *\n * Two things here are STRICTER than the generator, deliberately, and both are publishing rules\n * rather than expressibility ones (being stricter cannot break the acceptance contract — it can only\n * refuse something that would have generated):\n *\n * - an `unknown` VALUE TYPE anywhere (`Record<string, unknown>`, `unknown[]`, a bare `unknown`\n * field). The extractor maps it to a primitive and the generator publishes `{}`, which in JSON\n * Schema means \"anything\" — a partner-facing field with no shape, which is the defect the\n * unmapped guard exists for, arriving through a door the guard does not watch.\n * - an RPC whose response is `void`. Fire-and-forget is the CONTRACT of a `cloudtasks` or `cron`\n * endpoint and is allowed there; an RPC that answers nothing can never gain a field without a\n * breaking change, where a named empty response object grows additively forever.\n *\n * ## Why it lives in the rules engine and not in the doc parser\n *\n * `@webpieces/api-doc-model` is only ever pointed at contracts somebody chose to publish. `@ApiType`\n * is a PUBLISHING decision added later, on purpose — so a shape rule that only ran on contracts which\n * had already opted in would let a team discover, six months afterwards, that the type was never\n * expressible, by which time it is in partners' generated clients. Every check below runs on every\n * contract in the workspace.\n *\n * ## Root-level unions are NOT re-checked here\n *\n * `no-root-union-api-type` (#1009) already refuses them, workspace-wide, with its own config key and\n * its own per-site hatch. One implementation. The MCP half still reports one when it meets it,\n * because `McpSchemaRenderer` refuses it as its own backstop and this scan reports whatever the\n * renderer says — which is the correct division: the OpenAPI document publishes a root union\n * perfectly well, and only a tool schema cannot carry one.\n */\n\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport * as ts from 'typescript';\nimport {\n ApiDocExtractionError,\n ApiDocExtractor,\n ApiDocModel,\n DocumentedEndpoint,\n McpRenderError,\n McpSchemaRenderer,\n TypeRef,\n UnmappedType,\n} from '@webpieces/api-doc-model';\nimport { matchesAnyGlob } from '@webpieces/rules-config';\nimport { ProjectInfo } from '../project-info';\nimport { collectTsFiles, isTestFile } from './api-ast';\nimport {\n ApiContractDefect,\n ApiDocRule,\n ApiDocRulesFindings,\n ApiRuleFindings,\n DisableComment,\n McpExclusion,\n MCP_RULE,\n OPENAPI_RULE,\n} from './api-doc-rules';\nimport {\n ContractLines,\n RPC_KIND,\n unknownValueCure,\n VOID_RPC_CURE,\n carriesUnknown,\n classifyUnmapped,\n contractLinesOf,\n contractNamesIn,\n isVoidLike,\n toolFailures,\n} from './api-doc-rules-verdicts';\n\n/** `@ApiPath(` at COLUMN ZERO — a docstring that TALKS about a contract declares none. */\nconst DECLARES_CONTRACT = /^@ApiPath\\(/m;\n\n/** ONE contract file, and which of the two rules apply to the project that owns it. */\nclass ContractFile {\n constructor(\n public readonly absPath: string,\n public readonly openApi: boolean,\n public readonly mcp: boolean,\n ) {}\n}\n\n/** Where one declaration sits, already split out of the extractor's `File.ts:LINE:COL` spelling. */\nclass Site {\n constructor(\n public readonly absPath: string,\n public readonly line: number,\n ) {}\n\n /** `path/to/File.ts:LINE`, workspace-relative — what a refusal prints. */\n relativeTo(workspaceRoot: string): string {\n return `${path.relative(workspaceRoot, this.absPath)}:${this.line}`;\n }\n\n /** `abs/File.ts:12:5` -> a Site. An unparseable one falls back to line 1 of `fallback`. */\n // webpieces-disable no-function-outside-class -- static factory of this class\n static parse(location: string, fallback: string): Site {\n const match = location.match(/^(.*):(\\d+):\\d+$/);\n if (match === null) return new Site(fallback, 1);\n return new Site(match[1], Number(match[2]));\n }\n}\n\n/**\n * ONE rule's accumulator. It is what applies the per-site disable, so the disable semantics live in\n * exactly one place and cannot differ between the two rules.\n */\nclass DefectCollector {\n private readonly violations: ApiContractDefect[] = [];\n private readonly reasonless: ApiContractDefect[] = [];\n\n constructor(private readonly ruleName: string) {}\n\n /** The rule this collector reports under — what a shared defect's cure has to name. */\n rule(): string {\n return this.ruleName;\n }\n\n add(defect: ApiContractDefect, site: Site): void {\n const disable = DisableComment.readAt(site.absPath, site.line, this.ruleName);\n if (disable === undefined) {\n this.violations.push(defect);\n return;\n }\n if (!disable.hasReason) this.reasonless.push(defect);\n }\n\n findings(): ApiRuleFindings {\n return new ApiRuleFindings(\n DefectCollector.externalFirst(this.violations),\n DefectCollector.externalFirst(this.reasonless),\n );\n }\n\n /**\n * Partner-facing contracts first. The same defect is a different size depending on who reads the\n * document it would have been in, and a list that buries the `external-customer` ones among\n * thirty internal ones has hidden the only urgent line in it.\n */\n // webpieces-disable no-function-outside-class -- private static ordering of this class\n private static externalFirst(found: readonly ApiContractDefect[]): ApiContractDefect[] {\n return [...found].sort(\n (a: ApiContractDefect, b: ApiContractDefect) =>\n Number(b.isExternal()) - Number(a.isExternal()),\n );\n }\n}\n\n/**\n * Routes a defect to the rule that owns it.\n *\n * Every OpenAPI-level defect ALSO blocks MCP, so it is reported by whichever rule is running —\n * `api-rules-for-openapi` when that one is on, and `api-rules-for-mcp` alone when it is not. It is\n * never reported twice: a team running both would otherwise read every shared defect in two places\n * and have to work out that they are one.\n */\nclass DefectSink {\n constructor(\n private readonly openApi: DefectCollector | undefined,\n private readonly mcp: DefectCollector | undefined,\n ) {}\n\n /**\n * A defect that blocks the OpenAPI document, and therefore every tool on it too.\n *\n * The defect is BUILT from the rule that ends up reporting it, not handed in ready-made, because\n * a shared defect does not know in advance which rule will carry it: `api-rules-for-openapi` when\n * that one runs, and `api-rules-for-mcp` alone when it does not. A cure that named a fixed rule\n * would, on the mcp-only configuration, prescribe a `// webpieces-disable` line the collector\n * reading that site does not look for.\n */\n shared(build: (rule: string) => ApiContractDefect, site: Site): void {\n const target = this.openApi ?? this.mcp;\n if (target === undefined) return;\n target.add(build(target.rule()), site);\n }\n\n /** A defect that blocks ONE tool and nothing else. */\n mcpOnly(defect: ApiContractDefect, site: Site): void {\n this.mcp?.add(defect, site);\n }\n\n anyRuleRuns(): boolean {\n return this.openApi !== undefined || this.mcp !== undefined;\n }\n\n /** True when `api-rules-for-mcp` applies to this file — what the exclusion list is scoped to. */\n mcpRuns(): boolean {\n return this.mcp !== undefined;\n }\n}\n\n/**\n * Walks every project's `src`, extracts every `@ApiPath` contract with the generator's own\n * extractor, and judges the result against the two rules.\n *\n * ONE `ts.Program` over every contract file in the workspace, because a DTO a contract reaches\n * routinely lives in another project and the checker has to be able to follow the import — the same\n * reason the repo sweep in `@webpieces/api-doc-model`'s own spec builds one program rather than one\n * per file.\n */\nexport class ApiDocRulesScan {\n /**\n * Every `@InvalidEndpointForMcp` endpoint met on a file the MCP rule applies to.\n *\n * Collected even on a run with no findings at all, because restating them IS the feature: the\n * alternative considered in #1014 was a one-off warning when somebody adds one, and a warning\n * printed once at the moment of the decision is read by the one person who already knows.\n */\n private readonly exclusions: McpExclusion[] = [];\n\n constructor(\n private readonly workspaceRoot: string,\n private readonly projectInfos: Map<string, ProjectInfo>,\n /** OFF unless a caller read otherwise out of webpieces.config.json — see `defaultRules`. */\n private readonly openApiRule: ApiDocRule = ApiDocRule.off(OPENAPI_RULE),\n private readonly mcpRule: ApiDocRule = ApiDocRule.off(MCP_RULE),\n ) {}\n\n run(): ApiDocRulesFindings {\n if (!this.openApiRule.enabled && !this.mcpRule.enabled) return ApiDocRulesFindings.empty();\n const files = this.contractFiles();\n if (files.length === 0) return ApiDocRulesFindings.empty();\n\n const openApi = this.openApiRule.enabled ? new DefectCollector(OPENAPI_RULE) : undefined;\n const mcp = this.mcpRule.enabled ? new DefectCollector(MCP_RULE) : undefined;\n const program = ts.createProgram(\n files.map((file: ContractFile) => file.absPath),\n this.compilerOptions(),\n );\n const toolNames = new Map<string, string>();\n for (const file of files) {\n const sink = new DefectSink(\n file.openApi ? openApi : undefined,\n file.mcp ? mcp : undefined,\n );\n this.judgeFile(file, program, sink, toolNames);\n }\n return new ApiDocRulesFindings(\n openApi?.findings() ?? new ApiRuleFindings([], []),\n mcp?.findings() ?? new ApiRuleFindings([], []),\n this.exclusions,\n );\n }\n\n /** Every contract in one file, or the ONE refusal that stopped the file being read at all. */\n private judgeFile(\n file: ContractFile,\n program: ts.Program,\n sink: DefectSink,\n toolNames: Map<string, string>,\n ): void {\n if (!sink.anyRuleRuns()) return;\n const source = program.getSourceFile(file.absPath);\n if (source === undefined) return;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- an extraction refusal IS a finding; it is reported, not propagated\n try {\n for (const model of new ApiDocExtractor().extractAllFrom(program, source)) {\n this.judgeModel(model, source, sink, toolNames);\n }\n } catch (err: unknown) {\n //const error = toError(err);\n if (!(err instanceof ApiDocExtractionError)) throw err;\n this.reportExtractionFailure(err, file, source, sink);\n }\n }\n\n /**\n * An extraction that REFUSED. Reported under the shared list because it stops BOTH documents:\n * `@Endpoint` arguments that cannot be constant-folded, a bound on a non-numeric field, and the\n * `@ApiType(..., MCP)` ⇔ `@WpMcpTool` biconditional all fail here, before a model exists.\n */\n private reportExtractionFailure(\n error: ApiDocExtractionError,\n file: ContractFile,\n source: ts.SourceFile,\n sink: DefectSink,\n ): void {\n const site = Site.parse(error.location, file.absPath);\n sink.shared(\n (): ApiContractDefect => new ApiContractDefect(\n contractNamesIn(source)[0] ?? path.basename(file.absPath),\n '',\n `the contract cannot be read at all — ${error.message}`,\n site.relativeTo(this.workspaceRoot),\n error.cure,\n [],\n ),\n site,\n );\n }\n\n /** ONE contract: the shared OpenAPI checks, then the MCP-only ones. */\n private judgeModel(\n model: ApiDocModel,\n source: ts.SourceFile,\n sink: DefectSink,\n toolNames: Map<string, string>,\n ): void {\n const lines = contractLinesOf(source, model.contractName);\n this.judgeUnmapped(model, sink, source.fileName);\n this.judgeUnknownValues(model, sink, source.fileName);\n this.judgeRpcResponses(model, source, lines, sink);\n this.judgeTools(model, source, lines, sink, toolNames);\n }\n\n /** Everything `TypeResolver` could not represent — the generator's OWN verdict, re-worded. */\n private judgeUnmapped(model: ApiDocModel, sink: DefectSink, fallback: string): void {\n for (const unmapped of model.unmapped) {\n const site = Site.parse(unmapped.location, fallback);\n const verdict = classifyUnmapped(unmapped);\n sink.shared(\n (): ApiContractDefect => new ApiContractDefect(\n model.contractName,\n '',\n verdict.what,\n site.relativeTo(this.workspaceRoot),\n verdict.cure,\n model.apiTypes,\n ),\n site,\n );\n }\n }\n\n /** `Record<string, unknown>`, `unknown[]`, `x: unknown` — by VALUE TYPE, never by spelling. */\n private judgeUnknownValues(model: ApiDocModel, sink: DefectSink, fallback: string): void {\n for (const type of model.types.values()) {\n for (const field of type.fields) {\n if (!carriesUnknown(field.type)) continue;\n const site = Site.parse(field.location, fallback);\n sink.shared(\n (rule: string): ApiContractDefect =>\n new ApiContractDefect(\n model.contractName,\n '',\n `'${type.name}.${field.name}' publishes an 'unknown' value, so the ` +\n 'document states no shape for it at all',\n site.relativeTo(this.workspaceRoot),\n unknownValueCure(rule),\n model.apiTypes,\n ),\n site,\n );\n }\n }\n }\n\n /**\n * An RPC must NAME a response DTO, even an empty one.\n *\n * `Promise<void>` on a `cloudtasks` or `cron` endpoint is the CONTRACT — fire-and-forget, nothing\n * to shape — and is allowed. On an RPC it is a one-way door: a `void` response can never gain a\n * field without breaking every generated client, where `{}` grows additively forever. This is a\n * contract-EVOLUTION rule, which is why it is here and not in the MCP half: it is worth having on\n * an RPC that never becomes a tool.\n */\n private judgeRpcResponses(\n model: ApiDocModel,\n source: ts.SourceFile,\n lines: ContractLines,\n sink: DefectSink,\n ): void {\n for (const endpoint of model.endpoints) {\n if (endpoint.kind !== RPC_KIND) continue;\n if (endpoint.response !== undefined && !isVoidLike(endpoint.response)) continue;\n const site = new Site(source.fileName, lines.lineOf(endpoint.methodName));\n sink.shared(\n (): ApiContractDefect => new ApiContractDefect(\n model.contractName,\n endpoint.methodName,\n 'an RPC returns nothing a document can name (void, unknown, or no declared ' +\n 'return type)',\n site.relativeTo(this.workspaceRoot),\n VOID_RPC_CURE,\n model.apiTypes,\n ),\n site,\n );\n }\n }\n\n /** Everything `McpToolRegistry` refuses to boot on, plus whatever the renderer cannot render. */\n private judgeTools(\n model: ApiDocModel,\n source: ts.SourceFile,\n lines: ContractLines,\n sink: DefectSink,\n toolNames: Map<string, string>,\n ): void {\n const renderer = new McpSchemaRenderer(model);\n for (const endpoint of model.endpoints) {\n if (endpoint.invalidForMcp !== undefined) {\n if (!sink.mcpRuns()) continue;\n // @InvalidEndpointForMcp IS the answer to \"could this be a tool\". The decorator\n // carries the argument, so the rule asks nothing further and no webpieces-disable is\n // needed — a suppression would be a second, weaker spelling of the same declaration.\n this.exclusions.push(\n new McpExclusion(\n model.contractName,\n endpoint.methodName,\n endpoint.invalidForMcp,\n new Site(\n source.fileName,\n lines.lineOf(endpoint.methodName),\n ).relativeTo(this.workspaceRoot),\n ),\n );\n continue;\n }\n if (endpoint.mcpTool === undefined) continue;\n const site = new Site(source.fileName, lines.lineOf(endpoint.methodName));\n for (const failure of toolFailures(endpoint, renderer, toolNames, model)) {\n sink.mcpOnly(\n new ApiContractDefect(\n model.contractName,\n endpoint.methodName,\n failure.what,\n site.relativeTo(this.workspaceRoot),\n failure.cure,\n model.apiTypes,\n ),\n site,\n );\n }\n }\n for (const methodName of lines.toolsWithoutEndpoint) {\n const site = new Site(source.fileName, lines.lineOf(methodName));\n sink.mcpOnly(\n new ApiContractDefect(\n model.contractName,\n methodName,\n 'carries @WpMcpTool but is not an @Endpoint, so it is not routed at all',\n site.relativeTo(this.workspaceRoot),\n \"Add @Endpoint(POST, '/path', READ, RPC) to it, or drop the @WpMcpTool.\",\n model.apiTypes,\n ),\n site,\n );\n }\n }\n\n /** Every non-test `.ts` under a project's `src` whose text DECLARES an `@ApiPath` contract. */\n private contractFiles(): ContractFile[] {\n const found: ContractFile[] = [];\n for (const info of this.projectInfos.values()) {\n if (info.root === '' || info.root === '.') continue;\n const openApi =\n this.openApiRule.enabled &&\n !matchesAnyGlob(info.root, this.openApiRule.allowedPaths);\n const mcp =\n this.mcpRule.enabled && !matchesAnyGlob(info.root, this.mcpRule.allowedPaths);\n if (!openApi && !mcp) continue;\n const srcDir = path.join(path.resolve(this.workspaceRoot, info.root), 'src');\n if (!fs.existsSync(srcDir)) continue;\n for (const file of collectTsFiles(srcDir)) {\n if (isTestFile(file)) continue; // a fixture is not a published contract\n if (!DECLARES_CONTRACT.test(fs.readFileSync(file, 'utf8'))) continue;\n found.push(new ContractFile(file, openApi, mcp));\n }\n }\n return found.sort((a: ContractFile, b: ContractFile) => a.absPath.localeCompare(b.absPath));\n }\n\n /**\n * `tsconfig.base.json`'s options when the workspace has one, so an `@webpieces/*` import in a\n * contract RESOLVES and the checker can follow a DTO into another project. Without that the\n * resolver reports every cross-project type as unmapped, which would be a rule failing on its\n * own inability to read rather than on anything the author wrote.\n */\n private compilerOptions(): ts.CompilerOptions {\n const base = path.join(this.workspaceRoot, 'tsconfig.base.json');\n const declared = fs.existsSync(base)\n ? ts.parseJsonConfigFileContent(\n ts.readConfigFile(base, ts.sys.readFile).config,\n ts.sys,\n this.workspaceRoot,\n ).options\n : {};\n return {\n ...declared,\n noEmit: true,\n skipLibCheck: true,\n types: [],\n experimentalDecorators: true,\n };\n }\n}\n"]}
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The pure VERDICTS the api-contract rules print, split out of `api-doc-rules-scan.ts`, which owns
3
+ * the walk and is at its file-size limit.
4
+ *
5
+ * Nothing here touches the filesystem or the project graph. Each function answers one question about
6
+ * ONE declaration the extractor or the renderer has already ruled on, and turns it into the two
7
+ * halves every webpieces refusal carries — what is wrong, and what to do instead. The DECISION is
8
+ * never made here: `model.unmapped` is the extractor's and a render failure is the renderer's. What
9
+ * this file adds is the WORDING, which is the part a generic "no model representation for this type
10
+ * form" cannot give an author.
11
+ */
12
+ import * as ts from 'typescript';
13
+ import { ApiDocModel, DocumentedEndpoint, McpSchemaRenderer, TypeRef, UnmappedType } from '@webpieces/api-doc-model';
14
+ import { EndpointKind } from './api-relations';
15
+ /**
16
+ * The one trigger kind an MCP tool may have. A LITERAL, for the same reason `EndpointKind` beside it
17
+ * is one: this package deliberately does not depend on `@webpieces/core-util` at runtime, and the
18
+ * set the literal belongs to is pinned by that type.
19
+ */
20
+ export declare const RPC_KIND: EndpointKind;
21
+ /** ONE reason a declaration is refused, in the two halves every refusal prints. */
22
+ export declare class Verdict {
23
+ readonly what: string;
24
+ readonly cure: string;
25
+ constructor(what: string, cure: string);
26
+ }
27
+ /** Method name -> the line its declaration starts on, for one contract class. */
28
+ export declare class ContractLines {
29
+ readonly classLine: number;
30
+ readonly byMethod: ReadonlyMap<string, number>;
31
+ /** Methods carrying `@WpMcpTool` that do NOT also carry `@Endpoint`. */
32
+ readonly toolsWithoutEndpoint: readonly string[];
33
+ constructor(classLine: number, byMethod: ReadonlyMap<string, number>,
34
+ /** Methods carrying `@WpMcpTool` that do NOT also carry `@Endpoint`. */
35
+ toolsWithoutEndpoint: readonly string[]);
36
+ lineOf(methodName: string): number;
37
+ }
38
+ /**
39
+ * The cure for an `unknown` value, for whichever rule is REPORTING it.
40
+ *
41
+ * It takes the rule name rather than naming one, because this defect is shared: it blocks the
42
+ * OpenAPI document, so `DefectSink` routes it to `api-rules-for-openapi` when that rule is running
43
+ * and to `api-rules-for-mcp` when it is the only one. A hard-coded rule name would then hand an
44
+ * author, on the mcp-only configuration, a `// webpieces-disable api-rules-for-openapi` line that
45
+ * the collector reading the site does not look for — a cure that does not cure, which is the one
46
+ * failure mode a printed cure must not have.
47
+ */
48
+ export declare function unknownValueCure(rule: string): string;
49
+ /** The one cure for a `void` RPC. */
50
+ export declare const VOID_RPC_CURE: string;
51
+ /**
52
+ * Why the resolver could not map this type, said in the author's vocabulary.
53
+ *
54
+ * The DECISION to refuse is the extractor's and is not second-guessed here — every entry in
55
+ * `model.unmapped` becomes a defect. What this adds is the MESSAGE: an open enum and a mixed scalar
56
+ * union are the two shapes a real repo actually hits (measured: 6 of 98 endpoints, from exactly
57
+ * these two), and "no model representation for this type form" tells their author nothing.
58
+ */
59
+ export declare function classifyUnmapped(unmapped: UnmappedType): Verdict;
60
+ /**
61
+ * True when this type PUBLISHES an `unknown` value.
62
+ *
63
+ * It looks at the VALUE TYPE wherever it sits — the item of an array, the value of an open map, the
64
+ * field itself — and never at the `Record<>` spelling, because the real cases in the wild are not
65
+ * all Records: `params?: unknown[]` is the same defect written differently.
66
+ *
67
+ * It does NOT descend through a `ref`: a named DTO is its own entry in the model and its own fields
68
+ * are judged there, so descending would report the same field once per type that points at it.
69
+ */
70
+ export declare function carriesUnknown(ref: TypeRef): boolean;
71
+ /**
72
+ * `void`, `unknown` and `any` all reach the model as the same primitive — the resolver maps the
73
+ * three keywords onto one, because to a document they say the identical thing: nothing.
74
+ */
75
+ export declare function isVoidLike(ref: TypeRef): boolean;
76
+ /**
77
+ * Every reason this tool could not be served, in the order `McpToolRegistry` asks them at boot —
78
+ * unique name, trigger kind, HTTP auth, MCP auth — and then the renderer's own verdict.
79
+ *
80
+ * The registry's four are restated from the model rather than driven, because they are decorator
81
+ * PRESENCE facts the model already carries verbatim; the SCHEMA question is the one with a real
82
+ * implementation behind it, and that one is driven.
83
+ */
84
+ export declare function toolFailures(endpoint: DocumentedEndpoint, renderer: McpSchemaRenderer, toolNames: Map<string, string>, model: ApiDocModel): readonly Verdict[];
85
+ /** Every `@ApiPath` class name declared at the top level of a file, in declaration order. */
86
+ export declare function contractNamesIn(source: ts.SourceFile): string[];
87
+ /**
88
+ * Where each method of one contract starts, and which of them carry `@WpMcpTool` without
89
+ * `@Endpoint`.
90
+ *
91
+ * The line numbers exist because the MCP failures come from the RENDERER, whose location is a NAME
92
+ * (`Order.window`) rather than a file offset — it publishes a schema, not a diagnostic. Anchoring
93
+ * every tool failure at its METHOD is also where an author would write the disable: "this tool is
94
+ * deliberately not renderable" is a decision about the method, not about a field three DTOs down.
95
+ */
96
+ export declare function contractLinesOf(source: ts.SourceFile, contractName: string): ContractLines;