@webpieces/nx-webpieces-rules 0.4.807 → 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.
- package/package.json +7 -6
- package/src/executors/generate/executor.js +9 -1
- package/src/executors/generate/executor.js.map +1 -1
- package/src/lib/api-usage/api-contract-errors.d.ts +84 -1
- package/src/lib/api-usage/api-contract-errors.js +203 -1
- package/src/lib/api-usage/api-contract-errors.js.map +1 -1
- package/src/lib/api-usage/api-doc-rules-scan.d.ts +107 -0
- package/src/lib/api-usage/api-doc-rules-scan.js +361 -0
- package/src/lib/api-usage/api-doc-rules-scan.js.map +1 -0
- package/src/lib/api-usage/api-doc-rules-verdicts.d.ts +96 -0
- package/src/lib/api-usage/api-doc-rules-verdicts.js +308 -0
- package/src/lib/api-usage/api-doc-rules-verdicts.js.map +1 -0
- package/src/lib/api-usage/api-doc-rules.d.ts +149 -0
- package/src/lib/api-usage/api-doc-rules.js +242 -0
- package/src/lib/api-usage/api-doc-rules.js.map +1 -0
- package/src/lib/api-usage/api-relations-validator.d.ts +2 -1
- package/src/lib/api-usage/api-relations-validator.js.map +1 -1
- package/src/lib/api-usage/api-relations.d.ts +25 -0
- package/src/lib/api-usage/api-relations.js +28 -1
- package/src/lib/api-usage/api-relations.js.map +1 -1
- package/src/lib/api-usage/api-scanner.d.ts +23 -33
- package/src/lib/api-usage/api-scanner.js +34 -49
- package/src/lib/api-usage/api-scanner.js.map +1 -1
- package/src/lib/api-usage/root-union-scan.d.ts +149 -0
- package/src/lib/api-usage/root-union-scan.js +336 -0
- package/src/lib/api-usage/root-union-scan.js.map +1 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `no-root-union-api-type` — REFUSE a request or response type that IS a union.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this breaks the build instead of being a lint warning
|
|
6
|
+
*
|
|
7
|
+
* Both the OpenAI and the Anthropic function-calling APIs forbid `oneOf`/`anyOf`/`allOf` at the TOP
|
|
8
|
+
* LEVEL of a tool's parameter schema. A server sends its WHOLE tool list on every request, so ONE
|
|
9
|
+
* offending tool makes EVERY request return HTTP 400 — the entire client session is bricked, not
|
|
10
|
+
* just that tool, and the symptom a user reports is that every OTHER tool stopped working. It
|
|
11
|
+
* usually arrives by accident: an SDK turns a discriminated union at the root into `{oneOf: [...]}`.
|
|
12
|
+
* Live reports: imagekit-developer/imagekit-nodejs#150, posthog/posthog#61359, vercel/ai#21350,
|
|
13
|
+
* opentokenz/mcpx#28.
|
|
14
|
+
*
|
|
15
|
+
* NESTED composition — a union inside a property — is perfectly fine and is published as `oneOf`
|
|
16
|
+
* with its derived discriminator (#1009). Only the ROOT is the problem.
|
|
17
|
+
*
|
|
18
|
+
* ## Why it lives in the RULES ENGINE and not in the doc parser
|
|
19
|
+
*
|
|
20
|
+
* `@webpieces/api-doc-model` only ever visits contracts that declare `@ApiType`, so a rule
|
|
21
|
+
* implemented there could not — by construction — enforce anything on contracts that have not opted
|
|
22
|
+
* in yet, which is exactly the population this rule exists to protect. `@ApiType` is a PUBLISHING
|
|
23
|
+
* decision added later, on purpose; if the shape rules do not hold from the first line, then adding
|
|
24
|
+
* the annotation becomes a migration nobody expects, on a type already in partners' generated
|
|
25
|
+
* clients. The scan below walks EVERY `@ApiPath` contract in the workspace, `@ApiType` or not.
|
|
26
|
+
*
|
|
27
|
+
* ## Why it is PARSER-ONLY
|
|
28
|
+
*
|
|
29
|
+
* Same reason `ApiSourceIndexBuilder` is: a plain parse cannot be diverted to a decorator-erased
|
|
30
|
+
* `.d.ts` by module resolution, and it is cheap enough to run over every project. The cost is that a
|
|
31
|
+
* union alias declared in a package this workspace does not build is invisible — the same blind spot
|
|
32
|
+
* every other check in this directory has, and the same one `recoverFromDeclaration` documents.
|
|
33
|
+
*/
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.RootUnionScan = exports.RootUnionFindings = exports.RootUnionApiType = exports.RootUnionRule = exports.ROOT_UNION_RULE = void 0;
|
|
36
|
+
const tslib_1 = require("tslib");
|
|
37
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
38
|
+
const path = tslib_1.__importStar(require("path"));
|
|
39
|
+
const ts = tslib_1.__importStar(require("typescript"));
|
|
40
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
41
|
+
const rule_gate_1 = require("../rule-gate");
|
|
42
|
+
const api_ast_1 = require("./api-ast");
|
|
43
|
+
/** The rule name, as it is written in a disable comment and as a config key. */
|
|
44
|
+
exports.ROOT_UNION_RULE = rules_config_1.RULE_NAMES.NO_ROOT_UNION_API_TYPE;
|
|
45
|
+
/**
|
|
46
|
+
* `// webpieces-disable no-root-union-api-type -- <reason>`, with the reason CAPTURED so a
|
|
47
|
+
* reasonless one can be told apart from an absent one. A comma list of rules is the shared spelling
|
|
48
|
+
* (`disable-directives.ts`), so it is accepted here too.
|
|
49
|
+
*/
|
|
50
|
+
const DISABLE_RE = new RegExp(`//\\s*${rules_config_1.WEBPIECES_DISABLE}\\s+([\\w-]+(?:\\s*,\\s*[\\w-]+)*)(?:\\s*--\\s*(.*))?$`);
|
|
51
|
+
/**
|
|
52
|
+
* The rule's SWITCHES, resolved from webpieces.config.json once per scan.
|
|
53
|
+
*
|
|
54
|
+
* Read here, not inside the scan, so a unit test constructs the scan with explicit values and never
|
|
55
|
+
* touches a config file — and so the config is read exactly once per executor run, beside the
|
|
56
|
+
* `externalApiPaths` read that already happens there.
|
|
57
|
+
*/
|
|
58
|
+
class RootUnionRule {
|
|
59
|
+
enabled;
|
|
60
|
+
allowedPaths;
|
|
61
|
+
constructor(enabled,
|
|
62
|
+
/** Project roots this rule does not apply to — `allowedPaths` in the config. */
|
|
63
|
+
allowedPaths) {
|
|
64
|
+
this.enabled = enabled;
|
|
65
|
+
this.allowedPaths = allowedPaths;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The DEFAULT: armed, everywhere. A rule entry that is absent means RUN — never invent a default
|
|
69
|
+
* that silently disables a check (the same rule `RuleGate` states for the nx validators).
|
|
70
|
+
*/
|
|
71
|
+
// webpieces-disable no-function-outside-class -- static factory of this class
|
|
72
|
+
static enabledEverywhere() {
|
|
73
|
+
return new RootUnionRule(true, []);
|
|
74
|
+
}
|
|
75
|
+
/** `mode: OFF` and the time-box/branch hatches come from RuleGate, so there is ONE reading of them. */
|
|
76
|
+
// webpieces-disable no-function-outside-class -- static factory of this class
|
|
77
|
+
static fromConfig(workspaceRoot) {
|
|
78
|
+
if (new rule_gate_1.RuleGate().isDisabled(workspaceRoot, exports.ROOT_UNION_RULE, true)) {
|
|
79
|
+
return new RootUnionRule(false, []);
|
|
80
|
+
}
|
|
81
|
+
const rule = (0, rules_config_1.loadAndValidate)(workspaceRoot).resolved.rules.get(exports.ROOT_UNION_RULE);
|
|
82
|
+
const allowed = rule?.options['allowedPaths'];
|
|
83
|
+
return new RootUnionRule(true, Array.isArray(allowed) ? allowed : []);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
exports.RootUnionRule = RootUnionRule;
|
|
87
|
+
/** ONE contract method whose request or response type is ITSELF a union. */
|
|
88
|
+
class RootUnionApiType {
|
|
89
|
+
api;
|
|
90
|
+
method;
|
|
91
|
+
side;
|
|
92
|
+
typeName;
|
|
93
|
+
at;
|
|
94
|
+
disabledWithoutReason;
|
|
95
|
+
constructor(
|
|
96
|
+
/** The `@ApiPath` contract class. */
|
|
97
|
+
api,
|
|
98
|
+
/** The `@Endpoint` method on it. */
|
|
99
|
+
method,
|
|
100
|
+
/** `request` or `response` — which side carries the union. */
|
|
101
|
+
side,
|
|
102
|
+
/** The union type's NAME, as written on the method. */
|
|
103
|
+
typeName,
|
|
104
|
+
/** `path/to/File.ts:LINE`, workspace-relative. */
|
|
105
|
+
at,
|
|
106
|
+
/** True when a disable comment names this rule but gives NO reason. */
|
|
107
|
+
disabledWithoutReason) {
|
|
108
|
+
this.api = api;
|
|
109
|
+
this.method = method;
|
|
110
|
+
this.side = side;
|
|
111
|
+
this.typeName = typeName;
|
|
112
|
+
this.at = at;
|
|
113
|
+
this.disabledWithoutReason = disabledWithoutReason;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
exports.RootUnionApiType = RootUnionApiType;
|
|
117
|
+
/** What one scan found. Two lists because the two have different cures. */
|
|
118
|
+
class RootUnionFindings {
|
|
119
|
+
violations;
|
|
120
|
+
reasonlessDisables;
|
|
121
|
+
constructor(
|
|
122
|
+
/** Root-level unions with no disable at all. */
|
|
123
|
+
violations,
|
|
124
|
+
/**
|
|
125
|
+
* Sites that DID carry a disable for this rule and gave no reason. A reasonless disable is
|
|
126
|
+
* itself a violation: the blast radius here is every tool in a session, so a suppression has
|
|
127
|
+
* to carry an argument somebody wrote down and the next reader can weigh.
|
|
128
|
+
*/
|
|
129
|
+
reasonlessDisables) {
|
|
130
|
+
this.violations = violations;
|
|
131
|
+
this.reasonlessDisables = reasonlessDisables;
|
|
132
|
+
}
|
|
133
|
+
isEmpty() {
|
|
134
|
+
return this.violations.length === 0 && this.reasonlessDisables.length === 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
exports.RootUnionFindings = RootUnionFindings;
|
|
138
|
+
/** One `type X = A | B` alias found in the workspace, keyed in the index by its NAME. */
|
|
139
|
+
class UnionAlias {
|
|
140
|
+
name;
|
|
141
|
+
branches;
|
|
142
|
+
constructor(name,
|
|
143
|
+
/** The branch type names, in declaration order. */
|
|
144
|
+
branches) {
|
|
145
|
+
this.name = name;
|
|
146
|
+
this.branches = branches;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** One `@Endpoint` method's declared request/response type names, with where to point an author. */
|
|
150
|
+
class EndpointTypes {
|
|
151
|
+
api;
|
|
152
|
+
method;
|
|
153
|
+
requestType;
|
|
154
|
+
responseType;
|
|
155
|
+
at;
|
|
156
|
+
declarationText;
|
|
157
|
+
constructor(api, method, requestType, responseType, at,
|
|
158
|
+
/** The declaration's own lines, decorators included — where a disable comment may sit. */
|
|
159
|
+
declarationText) {
|
|
160
|
+
this.api = api;
|
|
161
|
+
this.method = method;
|
|
162
|
+
this.requestType = requestType;
|
|
163
|
+
this.responseType = responseType;
|
|
164
|
+
this.at = at;
|
|
165
|
+
this.declarationText = declarationText;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Walks every project's `src/**` once, indexing union aliases and contract methods, then joins the
|
|
170
|
+
* two. One pass, because the alias and the contract that uses it are routinely in different files
|
|
171
|
+
* and often in different projects.
|
|
172
|
+
*/
|
|
173
|
+
class RootUnionScan {
|
|
174
|
+
workspaceRoot;
|
|
175
|
+
projectInfos;
|
|
176
|
+
rule;
|
|
177
|
+
aliases = new Map();
|
|
178
|
+
endpoints = [];
|
|
179
|
+
/** Alias name -> the disable comment written on its declaration, when it carries one. */
|
|
180
|
+
aliasDisables = new Map();
|
|
181
|
+
constructor(workspaceRoot, projectInfos,
|
|
182
|
+
/** The rule's switches. ARMED unless a caller read otherwise out of webpieces.config.json. */
|
|
183
|
+
rule = RootUnionRule.enabledEverywhere()) {
|
|
184
|
+
this.workspaceRoot = workspaceRoot;
|
|
185
|
+
this.projectInfos = projectInfos;
|
|
186
|
+
this.rule = rule;
|
|
187
|
+
}
|
|
188
|
+
run() {
|
|
189
|
+
if (!this.rule.enabled)
|
|
190
|
+
return new RootUnionFindings([], []);
|
|
191
|
+
for (const info of this.projectInfos.values()) {
|
|
192
|
+
if (info.root === '' || info.root === '.')
|
|
193
|
+
continue;
|
|
194
|
+
if ((0, rules_config_1.matchesAnyGlob)(info.root, this.rule.allowedPaths))
|
|
195
|
+
continue;
|
|
196
|
+
this.indexProject(info);
|
|
197
|
+
}
|
|
198
|
+
const violations = [];
|
|
199
|
+
const reasonless = [];
|
|
200
|
+
for (const endpoint of this.endpoints) {
|
|
201
|
+
this.judge(endpoint, 'request', endpoint.requestType, violations, reasonless);
|
|
202
|
+
this.judge(endpoint, 'response', endpoint.responseType, violations, reasonless);
|
|
203
|
+
}
|
|
204
|
+
return new RootUnionFindings(violations, reasonless);
|
|
205
|
+
}
|
|
206
|
+
/** One side of one method: a union type name is a violation unless a REASONED disable covers it. */
|
|
207
|
+
judge(endpoint, side, typeName, violations, reasonless) {
|
|
208
|
+
if (typeName === null || !this.aliases.has(typeName))
|
|
209
|
+
return;
|
|
210
|
+
// Either the method or the alias may carry the disable: the method is the site that
|
|
211
|
+
// PUBLISHES the shape, and the alias is the declaration somebody will be looking at when
|
|
212
|
+
// they decide the shape is deliberate. Both are "the offending declaration".
|
|
213
|
+
const disable = DisableComment.readFrom(endpoint.declarationText) ?? this.aliasDisables.get(typeName);
|
|
214
|
+
const found = new RootUnionApiType(endpoint.api, endpoint.method, side, typeName, endpoint.at, disable !== undefined && !disable.hasReason);
|
|
215
|
+
if (disable === undefined) {
|
|
216
|
+
violations.push(found);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (!disable.hasReason)
|
|
220
|
+
reasonless.push(found);
|
|
221
|
+
}
|
|
222
|
+
indexProject(info) {
|
|
223
|
+
const srcDir = path.join(path.resolve(this.workspaceRoot, info.root), 'src');
|
|
224
|
+
if (!fs.existsSync(srcDir))
|
|
225
|
+
return;
|
|
226
|
+
for (const file of (0, api_ast_1.collectTsFiles)(srcDir)) {
|
|
227
|
+
if ((0, api_ast_1.isTestFile)(file))
|
|
228
|
+
continue; // a fixture is not a published contract
|
|
229
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
230
|
+
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
231
|
+
this.indexNode(sourceFile, sourceFile);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
indexNode(node, sourceFile) {
|
|
235
|
+
if (ts.isTypeAliasDeclaration(node))
|
|
236
|
+
this.indexAlias(node, sourceFile);
|
|
237
|
+
if (ts.isClassDeclaration(node))
|
|
238
|
+
this.indexContract(node, sourceFile);
|
|
239
|
+
ts.forEachChild(node, (child) => this.indexNode(child, sourceFile));
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* `type X = A | B` where at least two branches are NAMED types.
|
|
243
|
+
*
|
|
244
|
+
* A union of string literals (`type Phase = 'placed' | 'done'`) is deliberately NOT one: it
|
|
245
|
+
* publishes as `enum`, which is a scalar and carries no `oneOf` at all. `| null` / `| undefined`
|
|
246
|
+
* branches are dropped for the same reason the doc model drops them — they are nullability, not
|
|
247
|
+
* composition.
|
|
248
|
+
*/
|
|
249
|
+
indexAlias(alias, sourceFile) {
|
|
250
|
+
if (!ts.isUnionTypeNode(alias.type))
|
|
251
|
+
return;
|
|
252
|
+
const branches = [];
|
|
253
|
+
for (const branch of alias.type.types) {
|
|
254
|
+
if (RootUnionScan.isNullish(branch))
|
|
255
|
+
continue;
|
|
256
|
+
const named = (0, api_ast_1.typeReferenceName)(branch);
|
|
257
|
+
if (named === null)
|
|
258
|
+
return; // not a composition of named object types
|
|
259
|
+
branches.push(named);
|
|
260
|
+
}
|
|
261
|
+
if (branches.length < 2)
|
|
262
|
+
return;
|
|
263
|
+
const name = alias.name.text;
|
|
264
|
+
this.aliases.set(name, new UnionAlias(name, branches));
|
|
265
|
+
const disable = DisableComment.readFrom(RootUnionScan.textOf(alias, sourceFile));
|
|
266
|
+
if (disable !== undefined)
|
|
267
|
+
this.aliasDisables.set(name, disable);
|
|
268
|
+
}
|
|
269
|
+
// webpieces-disable no-function-outside-class -- private static predicate of this class
|
|
270
|
+
static isNullish(node) {
|
|
271
|
+
if (node.kind === ts.SyntaxKind.UndefinedKeyword ||
|
|
272
|
+
node.kind === ts.SyntaxKind.NullKeyword) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
return ts.isLiteralTypeNode(node) && node.literal.kind === ts.SyntaxKind.NullKeyword;
|
|
276
|
+
}
|
|
277
|
+
/** Every `@Endpoint` method of an `abstract class` carrying `@ApiPath` — `@ApiType` or not. */
|
|
278
|
+
indexContract(cls, sourceFile) {
|
|
279
|
+
if (!(0, api_ast_1.isAbstractClass)(cls) || !(0, api_ast_1.hasClassDecorator)(cls, 'ApiPath') || !cls.name)
|
|
280
|
+
return;
|
|
281
|
+
const api = cls.name.text;
|
|
282
|
+
for (const member of cls.members) {
|
|
283
|
+
if (!ts.isMethodDeclaration(member) || !ts.isIdentifier(member.name))
|
|
284
|
+
continue;
|
|
285
|
+
if ((0, api_ast_1.memberDecorator)(member, 'Endpoint') === null)
|
|
286
|
+
continue;
|
|
287
|
+
this.endpoints.push(new EndpointTypes(api, member.name.text, (0, api_ast_1.typeReferenceName)(member.parameters[0]?.type), RootUnionScan.awaitedName(member.type), this.locate(member, sourceFile), RootUnionScan.textOf(member, sourceFile)));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** `Promise<T>` -> `T`'s name; a bare `T` is read as itself, so both spellings are covered. */
|
|
291
|
+
// webpieces-disable no-function-outside-class -- private static accessor of this class
|
|
292
|
+
static awaitedName(declared) {
|
|
293
|
+
if (declared === undefined || !ts.isTypeReferenceNode(declared))
|
|
294
|
+
return null;
|
|
295
|
+
const outer = (0, api_ast_1.typeReferenceName)(declared);
|
|
296
|
+
if (outer !== 'Promise')
|
|
297
|
+
return outer;
|
|
298
|
+
const args = declared.typeArguments ?? [];
|
|
299
|
+
return args.length === 1 ? (0, api_ast_1.typeReferenceName)(args[0]) : null;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The declaration's own source text INCLUDING its leading trivia, so a disable comment written
|
|
303
|
+
* on the line above the first decorator is part of what is read.
|
|
304
|
+
*/
|
|
305
|
+
// webpieces-disable no-function-outside-class -- private static accessor of this class
|
|
306
|
+
static textOf(node, sourceFile) {
|
|
307
|
+
return sourceFile.text.slice(node.getFullStart(), node.getEnd());
|
|
308
|
+
}
|
|
309
|
+
locate(node, sourceFile) {
|
|
310
|
+
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
311
|
+
return `${path.relative(this.workspaceRoot, sourceFile.fileName)}:${position.line + 1}`;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
exports.RootUnionScan = RootUnionScan;
|
|
315
|
+
/** ONE `webpieces-disable` naming this rule, and whether it gave a reason. */
|
|
316
|
+
class DisableComment {
|
|
317
|
+
hasReason;
|
|
318
|
+
constructor(hasReason) {
|
|
319
|
+
this.hasReason = hasReason;
|
|
320
|
+
}
|
|
321
|
+
/** The FIRST disable naming this rule in `text`, or undefined when it names none. */
|
|
322
|
+
// webpieces-disable no-function-outside-class -- static factory of this class
|
|
323
|
+
static readFrom(text) {
|
|
324
|
+
for (const line of text.split('\n')) {
|
|
325
|
+
const match = line.match(DISABLE_RE);
|
|
326
|
+
if (match === null)
|
|
327
|
+
continue;
|
|
328
|
+
const named = match[1].split(',').map((each) => each.trim());
|
|
329
|
+
if (!named.includes(exports.ROOT_UNION_RULE))
|
|
330
|
+
continue;
|
|
331
|
+
return new DisableComment((match[2] ?? '').trim() !== '');
|
|
332
|
+
}
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
//# sourceMappingURL=root-union-scan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"root-union-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/nx-webpieces-rules/src/lib/api-usage/root-union-scan.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `no-root-union-api-type` — REFUSE a request or response type that IS a union.\n *\n * ## Why this breaks the build instead of being a lint warning\n *\n * Both the OpenAI and the Anthropic function-calling APIs forbid `oneOf`/`anyOf`/`allOf` at the TOP\n * LEVEL of a tool's parameter schema. A server sends its WHOLE tool list on every request, so ONE\n * offending tool makes EVERY request return HTTP 400 — the entire client session is bricked, not\n * just that tool, and the symptom a user reports is that every OTHER tool stopped working. It\n * usually arrives by accident: an SDK turns a discriminated union at the root into `{oneOf: [...]}`.\n * Live reports: imagekit-developer/imagekit-nodejs#150, posthog/posthog#61359, vercel/ai#21350,\n * opentokenz/mcpx#28.\n *\n * NESTED composition — a union inside a property — is perfectly fine and is published as `oneOf`\n * with its derived discriminator (#1009). Only the ROOT is the problem.\n *\n * ## Why it lives in the RULES ENGINE and not in the doc parser\n *\n * `@webpieces/api-doc-model` only ever visits contracts that declare `@ApiType`, so a rule\n * implemented there could not — by construction — enforce anything on contracts that have not opted\n * in yet, which is exactly the population this rule exists to protect. `@ApiType` is a PUBLISHING\n * decision added later, on purpose; if the shape rules do not hold from the first line, then adding\n * the annotation becomes a migration nobody expects, on a type already in partners' generated\n * clients. The scan below walks EVERY `@ApiPath` contract in the workspace, `@ApiType` or not.\n *\n * ## Why it is PARSER-ONLY\n *\n * Same reason `ApiSourceIndexBuilder` is: a plain parse cannot be diverted to a decorator-erased\n * `.d.ts` by module resolution, and it is cheap enough to run over every project. The cost is that a\n * union alias declared in a package this workspace does not build is invisible — the same blind spot\n * every other check in this directory has, and the same one `recoverFromDeclaration` documents.\n */\n\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport * as ts from 'typescript';\nimport {\n loadAndValidate,\n matchesAnyGlob,\n WEBPIECES_DISABLE,\n RULE_NAMES,\n} from '@webpieces/rules-config';\nimport { ProjectInfo } from '../project-info';\nimport { RuleGate } from '../rule-gate';\nimport {\n collectTsFiles,\n hasClassDecorator,\n isAbstractClass,\n isTestFile,\n memberDecorator,\n typeReferenceName,\n} from './api-ast';\n\n/** The rule name, as it is written in a disable comment and as a config key. */\nexport const ROOT_UNION_RULE = RULE_NAMES.NO_ROOT_UNION_API_TYPE;\n\n/**\n * `// webpieces-disable no-root-union-api-type -- <reason>`, with the reason CAPTURED so a\n * reasonless one can be told apart from an absent one. A comma list of rules is the shared spelling\n * (`disable-directives.ts`), so it is accepted here too.\n */\nconst DISABLE_RE = new RegExp(\n `//\\\\s*${WEBPIECES_DISABLE}\\\\s+([\\\\w-]+(?:\\\\s*,\\\\s*[\\\\w-]+)*)(?:\\\\s*--\\\\s*(.*))?$`,\n);\n\n/**\n * The rule's SWITCHES, resolved from webpieces.config.json once per scan.\n *\n * Read here, not inside the scan, so a unit test constructs the scan with explicit values and never\n * touches a config file — and so the config is read exactly once per executor run, beside the\n * `externalApiPaths` read that already happens there.\n */\nexport class RootUnionRule {\n constructor(\n public readonly enabled: boolean,\n /** Project roots this rule does not apply to — `allowedPaths` in the config. */\n public readonly allowedPaths: readonly string[],\n ) {}\n\n /**\n * The DEFAULT: armed, everywhere. A rule entry that is absent means RUN — never invent a default\n * that silently disables a check (the same rule `RuleGate` states for the nx validators).\n */\n // webpieces-disable no-function-outside-class -- static factory of this class\n static enabledEverywhere(): RootUnionRule {\n return new RootUnionRule(true, []);\n }\n\n /** `mode: OFF` and the time-box/branch hatches come from RuleGate, so there is ONE reading of them. */\n // webpieces-disable no-function-outside-class -- static factory of this class\n static fromConfig(workspaceRoot: string): RootUnionRule {\n if (new RuleGate().isDisabled(workspaceRoot, ROOT_UNION_RULE, true)) {\n return new RootUnionRule(false, []);\n }\n const rule = loadAndValidate(workspaceRoot).resolved.rules.get(ROOT_UNION_RULE);\n const allowed = rule?.options['allowedPaths'];\n return new RootUnionRule(true, Array.isArray(allowed) ? (allowed as string[]) : []);\n }\n}\n\n/** ONE contract method whose request or response type is ITSELF a union. */\nexport class RootUnionApiType {\n constructor(\n /** The `@ApiPath` contract class. */\n public readonly api: string,\n /** The `@Endpoint` method on it. */\n public readonly method: string,\n /** `request` or `response` — which side carries the union. */\n public readonly side: string,\n /** The union type's NAME, as written on the method. */\n public readonly typeName: string,\n /** `path/to/File.ts:LINE`, workspace-relative. */\n public readonly at: string,\n /** True when a disable comment names this rule but gives NO reason. */\n public readonly disabledWithoutReason: boolean,\n ) {}\n}\n\n/** What one scan found. Two lists because the two have different cures. */\nexport class RootUnionFindings {\n constructor(\n /** Root-level unions with no disable at all. */\n public readonly violations: readonly RootUnionApiType[],\n /**\n * Sites that DID carry a disable for this rule and gave no reason. A reasonless disable is\n * itself a violation: the blast radius here is every tool in a session, so a suppression has\n * to carry an argument somebody wrote down and the next reader can weigh.\n */\n public readonly reasonlessDisables: readonly RootUnionApiType[],\n ) {}\n\n isEmpty(): boolean {\n return this.violations.length === 0 && this.reasonlessDisables.length === 0;\n }\n}\n\n/** One `type X = A | B` alias found in the workspace, keyed in the index by its NAME. */\nclass UnionAlias {\n constructor(\n public readonly name: string,\n /** The branch type names, in declaration order. */\n public readonly branches: readonly string[],\n ) {}\n}\n\n/** One `@Endpoint` method's declared request/response type names, with where to point an author. */\nclass EndpointTypes {\n constructor(\n public readonly api: string,\n public readonly method: string,\n public readonly requestType: string | null,\n public readonly responseType: string | null,\n public readonly at: string,\n /** The declaration's own lines, decorators included — where a disable comment may sit. */\n public readonly declarationText: string,\n ) {}\n}\n\n/**\n * Walks every project's `src/**` once, indexing union aliases and contract methods, then joins the\n * two. One pass, because the alias and the contract that uses it are routinely in different files\n * and often in different projects.\n */\nexport class RootUnionScan {\n private readonly aliases = new Map<string, UnionAlias>();\n private readonly endpoints: EndpointTypes[] = [];\n /** Alias name -> the disable comment written on its declaration, when it carries one. */\n private readonly aliasDisables = new Map<string, DisableComment>();\n\n constructor(\n private readonly workspaceRoot: string,\n private readonly projectInfos: Map<string, ProjectInfo>,\n /** The rule's switches. ARMED unless a caller read otherwise out of webpieces.config.json. */\n private readonly rule: RootUnionRule = RootUnionRule.enabledEverywhere(),\n ) {}\n\n run(): RootUnionFindings {\n if (!this.rule.enabled) return new RootUnionFindings([], []);\n for (const info of this.projectInfos.values()) {\n if (info.root === '' || info.root === '.') continue;\n if (matchesAnyGlob(info.root, this.rule.allowedPaths)) continue;\n this.indexProject(info);\n }\n const violations: RootUnionApiType[] = [];\n const reasonless: RootUnionApiType[] = [];\n for (const endpoint of this.endpoints) {\n this.judge(endpoint, 'request', endpoint.requestType, violations, reasonless);\n this.judge(endpoint, 'response', endpoint.responseType, violations, reasonless);\n }\n return new RootUnionFindings(violations, reasonless);\n }\n\n /** One side of one method: a union type name is a violation unless a REASONED disable covers it. */\n private judge(\n endpoint: EndpointTypes,\n side: string,\n typeName: string | null,\n violations: RootUnionApiType[],\n reasonless: RootUnionApiType[],\n ): void {\n if (typeName === null || !this.aliases.has(typeName)) return;\n // Either the method or the alias may carry the disable: the method is the site that\n // PUBLISHES the shape, and the alias is the declaration somebody will be looking at when\n // they decide the shape is deliberate. Both are \"the offending declaration\".\n const disable =\n DisableComment.readFrom(endpoint.declarationText) ?? this.aliasDisables.get(typeName);\n const found = new RootUnionApiType(\n endpoint.api,\n endpoint.method,\n side,\n typeName,\n endpoint.at,\n disable !== undefined && !disable.hasReason,\n );\n if (disable === undefined) {\n violations.push(found);\n return;\n }\n if (!disable.hasReason) reasonless.push(found);\n }\n\n private indexProject(info: ProjectInfo): void {\n const srcDir = path.join(path.resolve(this.workspaceRoot, info.root), 'src');\n if (!fs.existsSync(srcDir)) return;\n for (const file of collectTsFiles(srcDir)) {\n if (isTestFile(file)) continue; // a fixture is not a published contract\n const text = fs.readFileSync(file, 'utf8');\n const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);\n this.indexNode(sourceFile, sourceFile);\n }\n }\n\n private indexNode(node: ts.Node, sourceFile: ts.SourceFile): void {\n if (ts.isTypeAliasDeclaration(node)) this.indexAlias(node, sourceFile);\n if (ts.isClassDeclaration(node)) this.indexContract(node, sourceFile);\n ts.forEachChild(node, (child: ts.Node) => this.indexNode(child, sourceFile));\n }\n\n /**\n * `type X = A | B` where at least two branches are NAMED types.\n *\n * A union of string literals (`type Phase = 'placed' | 'done'`) is deliberately NOT one: it\n * publishes as `enum`, which is a scalar and carries no `oneOf` at all. `| null` / `| undefined`\n * branches are dropped for the same reason the doc model drops them — they are nullability, not\n * composition.\n */\n private indexAlias(alias: ts.TypeAliasDeclaration, sourceFile: ts.SourceFile): void {\n if (!ts.isUnionTypeNode(alias.type)) return;\n const branches: string[] = [];\n for (const branch of alias.type.types) {\n if (RootUnionScan.isNullish(branch)) continue;\n const named = typeReferenceName(branch);\n if (named === null) return; // not a composition of named object types\n branches.push(named);\n }\n if (branches.length < 2) return;\n const name = alias.name.text;\n this.aliases.set(name, new UnionAlias(name, branches));\n const disable = DisableComment.readFrom(RootUnionScan.textOf(alias, sourceFile));\n if (disable !== undefined) this.aliasDisables.set(name, disable);\n }\n\n // webpieces-disable no-function-outside-class -- private static predicate of this class\n private static isNullish(node: ts.TypeNode): boolean {\n if (\n node.kind === ts.SyntaxKind.UndefinedKeyword ||\n node.kind === ts.SyntaxKind.NullKeyword\n ) {\n return true;\n }\n return ts.isLiteralTypeNode(node) && node.literal.kind === ts.SyntaxKind.NullKeyword;\n }\n\n /** Every `@Endpoint` method of an `abstract class` carrying `@ApiPath` — `@ApiType` or not. */\n private indexContract(cls: ts.ClassDeclaration, sourceFile: ts.SourceFile): void {\n if (!isAbstractClass(cls) || !hasClassDecorator(cls, 'ApiPath') || !cls.name) return;\n const api = cls.name.text;\n for (const member of cls.members) {\n if (!ts.isMethodDeclaration(member) || !ts.isIdentifier(member.name)) continue;\n if (memberDecorator(member, 'Endpoint') === null) continue;\n this.endpoints.push(\n new EndpointTypes(\n api,\n member.name.text,\n typeReferenceName(member.parameters[0]?.type),\n RootUnionScan.awaitedName(member.type),\n this.locate(member, sourceFile),\n RootUnionScan.textOf(member, sourceFile),\n ),\n );\n }\n }\n\n /** `Promise<T>` -> `T`'s name; a bare `T` is read as itself, so both spellings are covered. */\n // webpieces-disable no-function-outside-class -- private static accessor of this class\n private static awaitedName(declared: ts.TypeNode | undefined): string | null {\n if (declared === undefined || !ts.isTypeReferenceNode(declared)) return null;\n const outer = typeReferenceName(declared);\n if (outer !== 'Promise') return outer;\n const args = declared.typeArguments ?? [];\n return args.length === 1 ? typeReferenceName(args[0]) : null;\n }\n\n /**\n * The declaration's own source text INCLUDING its leading trivia, so a disable comment written\n * on the line above the first decorator is part of what is read.\n */\n // webpieces-disable no-function-outside-class -- private static accessor of this class\n private static textOf(node: ts.Node, sourceFile: ts.SourceFile): string {\n return sourceFile.text.slice(node.getFullStart(), node.getEnd());\n }\n\n private locate(node: ts.Node, sourceFile: ts.SourceFile): string {\n const position = sourceFile.getLineAndCharacterOfPosition(node.getStart());\n return `${path.relative(this.workspaceRoot, sourceFile.fileName)}:${position.line + 1}`;\n }\n}\n\n/** ONE `webpieces-disable` naming this rule, and whether it gave a reason. */\nclass DisableComment {\n constructor(public readonly hasReason: boolean) {}\n\n /** The FIRST disable naming this rule in `text`, or undefined when it names none. */\n // webpieces-disable no-function-outside-class -- static factory of this class\n static readFrom(text: string): DisableComment | undefined {\n for (const line of text.split('\\n')) {\n const match = line.match(DISABLE_RE);\n if (match === null) continue;\n const named = match[1].split(',').map((each: string): string => each.trim());\n if (!named.includes(ROOT_UNION_RULE)) continue;\n return new DisableComment((match[2] ?? '').trim() !== '');\n }\n return undefined;\n }\n}\n"]}
|