@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.
@@ -0,0 +1,242 @@
1
+ "use strict";
2
+ /**
3
+ * The DATA and the SWITCHES of `api-rules-for-openapi` and `api-rules-for-mcp` (#1011).
4
+ *
5
+ * The scan itself is in `api-doc-rules-scan.ts`; this file holds what a caller reads back and the
6
+ * per-rule config, so a unit test can construct either without touching a config file and so the
7
+ * config is read exactly once per executor run.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.DisableComment = exports.ApiDocRule = exports.ApiDocRulesFindings = exports.McpExclusion = exports.ApiRuleFindings = exports.ApiContractDefect = exports.EXTERNAL_CUSTOMER_API_TYPE = exports.MCP_RULE = exports.OPENAPI_RULE = void 0;
11
+ const tslib_1 = require("tslib");
12
+ const fs = tslib_1.__importStar(require("fs"));
13
+ const rules_config_1 = require("@webpieces/rules-config");
14
+ const rule_gate_1 = require("../rule-gate");
15
+ /** As written in a disable comment and as a config key. */
16
+ exports.OPENAPI_RULE = rules_config_1.RULE_NAMES.API_RULES_FOR_OPENAPI;
17
+ exports.MCP_RULE = rules_config_1.RULE_NAMES.API_RULES_FOR_MCP;
18
+ /** The `@ApiType` value that means "a partner reads this document". */
19
+ exports.EXTERNAL_CUSTOMER_API_TYPE = 'external-customer';
20
+ /**
21
+ * `// webpieces-disable <rule>[, <rule2>] -- <reason>`, with the reason CAPTURED so a reasonless
22
+ * disable can be told apart from an absent one. Identical to the spelling `root-union-scan` reads.
23
+ */
24
+ const DISABLE_RE = new RegExp(`//\\s*${rules_config_1.WEBPIECES_DISABLE}\\s+([\\w-]+(?:\\s*,\\s*[\\w-]+)*)(?:\\s*--\\s*(.*))?$`);
25
+ /** How far above a declaration a disable comment may sit before it is somebody else's comment. */
26
+ const DISABLE_LOOKBACK_LINES = 40;
27
+ /**
28
+ * ONE defect on ONE contract.
29
+ *
30
+ * `exposure` is the contract's `@ApiType` list, and it is carried rather than derived because the
31
+ * SAME defect is louder on a contract that reaches `external-customer`: an unshaped field on a
32
+ * partner document costs a partner something, where the same field on a service-to-service contract
33
+ * costs a colleague a question. The refusal sorts and labels on it.
34
+ */
35
+ class ApiContractDefect {
36
+ api;
37
+ method;
38
+ what;
39
+ at;
40
+ cure;
41
+ exposure;
42
+ constructor(
43
+ /** The `@ApiPath` contract class. */
44
+ api,
45
+ /** The `@Endpoint` method, or `''` for a contract-level or DTO-level defect. */
46
+ method,
47
+ /** What is wrong, in one line, naming the declaration. */
48
+ what,
49
+ /** `path/to/File.ts:LINE`, workspace-relative. */
50
+ at,
51
+ /** What to do instead, in one sentence. */
52
+ cure,
53
+ /** The contract's `@ApiType` list — `svc-to-svc` when it declares none. */
54
+ exposure) {
55
+ this.api = api;
56
+ this.method = method;
57
+ this.what = what;
58
+ this.at = at;
59
+ this.cure = cure;
60
+ this.exposure = exposure;
61
+ }
62
+ /** True when this contract feeds the PARTNER-facing document. */
63
+ isExternal() {
64
+ return this.exposure.includes(exports.EXTERNAL_CUSTOMER_API_TYPE);
65
+ }
66
+ /** `Api.method` or `Api` — what a reader opens. */
67
+ where() {
68
+ return this.method === '' ? this.api : `${this.api}.${this.method}`;
69
+ }
70
+ }
71
+ exports.ApiContractDefect = ApiContractDefect;
72
+ /** What ONE rule found: real defects, and disables of it that gave no reason. */
73
+ class ApiRuleFindings {
74
+ violations;
75
+ reasonlessDisables;
76
+ constructor(violations,
77
+ /**
78
+ * Sites that named this rule in a disable and wrote no reason. A reasonless disable is
79
+ * itself a violation: the point of the per-site hatch is the ARGUMENT, which is the only
80
+ * part the next reader can weigh.
81
+ */
82
+ reasonlessDisables) {
83
+ this.violations = violations;
84
+ this.reasonlessDisables = reasonlessDisables;
85
+ }
86
+ isEmpty() {
87
+ return this.violations.length === 0 && this.reasonlessDisables.length === 0;
88
+ }
89
+ }
90
+ exports.ApiRuleFindings = ApiRuleFindings;
91
+ /**
92
+ * ONE endpoint declared PERMANENTLY outside MCP by `@InvalidEndpointForMcp('<reason>')` (#1014).
93
+ *
94
+ * Not a violation and not a suppression — a DECLARATION, which is why it is carried beside the
95
+ * findings rather than in them. `api-rules-for-mcp` restates the whole list, with reasons, on every
96
+ * run: an exclusion announced once at the moment somebody added it is an exclusion nobody will read
97
+ * again, and a build-time warning at add time would be read exactly as little.
98
+ */
99
+ class McpExclusion {
100
+ api;
101
+ method;
102
+ reason;
103
+ at;
104
+ constructor(api, method,
105
+ /** The decorator's reason, verbatim. Empty only when the argument could not be folded. */
106
+ reason,
107
+ /** `path/to/File.ts:LINE`, workspace-relative. */
108
+ at) {
109
+ this.api = api;
110
+ this.method = method;
111
+ this.reason = reason;
112
+ this.at = at;
113
+ }
114
+ }
115
+ exports.McpExclusion = McpExclusion;
116
+ /** Both rules' findings from ONE scan — one program build, two verdicts. */
117
+ class ApiDocRulesFindings {
118
+ openApi;
119
+ mcp;
120
+ mcpExclusions;
121
+ constructor(openApi, mcp,
122
+ /** Every `@InvalidEndpointForMcp` endpoint the MCP rule looked at, in declaration order. */
123
+ mcpExclusions = []) {
124
+ this.openApi = openApi;
125
+ this.mcp = mcp;
126
+ this.mcpExclusions = mcpExclusions;
127
+ }
128
+ // webpieces-disable no-function-outside-class -- static factory of this class
129
+ static empty() {
130
+ return new ApiDocRulesFindings(new ApiRuleFindings([], []), new ApiRuleFindings([], []), []);
131
+ }
132
+ }
133
+ exports.ApiDocRulesFindings = ApiDocRulesFindings;
134
+ /**
135
+ * ONE rule's switches, resolved from webpieces.config.json once per scan.
136
+ *
137
+ * The DEFAULT is OFF, and unlike an absent entry elsewhere that is deliberate: `defaultRules` in
138
+ * `@webpieces/rules-config` carries `mode: 'OFF'` for both, and the argument for it is written
139
+ * there rather than here so there is one place to read it.
140
+ */
141
+ class ApiDocRule {
142
+ name;
143
+ enabled;
144
+ allowedPaths;
145
+ constructor(name, enabled,
146
+ /** Project roots this rule does not apply to — `allowedPaths` in the config. */
147
+ allowedPaths) {
148
+ this.name = name;
149
+ this.enabled = enabled;
150
+ this.allowedPaths = allowedPaths;
151
+ }
152
+ /** ARMED, everywhere — what a unit test constructs, and what an opted-in repo resolves to. */
153
+ // webpieces-disable no-function-outside-class -- static factory of this class
154
+ static armed(name) {
155
+ return new ApiDocRule(name, true, []);
156
+ }
157
+ // webpieces-disable no-function-outside-class -- static factory of this class
158
+ static off(name) {
159
+ return new ApiDocRule(name, false, []);
160
+ }
161
+ /** `mode: OFF` and the time-box/branch hatches come from RuleGate, so there is ONE reading. */
162
+ // webpieces-disable no-function-outside-class -- static factory of this class
163
+ static fromConfig(workspaceRoot, name) {
164
+ if (new rule_gate_1.RuleGate().isDisabled(workspaceRoot, name, true)) {
165
+ return ApiDocRule.off(name);
166
+ }
167
+ const rule = (0, rules_config_1.loadAndValidate)(workspaceRoot).resolved.rules.get(name);
168
+ const allowed = rule?.options['allowedPaths'];
169
+ return new ApiDocRule(name, true, Array.isArray(allowed) ? allowed : []);
170
+ }
171
+ }
172
+ exports.ApiDocRule = ApiDocRule;
173
+ /** ONE `webpieces-disable` naming a rule, and whether it gave a reason. */
174
+ class DisableComment {
175
+ hasReason;
176
+ constructor(hasReason) {
177
+ this.hasReason = hasReason;
178
+ }
179
+ /**
180
+ * The disable naming `rule` on the declaration at `line` (1-based) of `file`, or undefined.
181
+ *
182
+ * It walks UPWARD over the declaration's own leading trivia — blank lines, `//` comments, a
183
+ * JSDoc block, and the decorators between them — because that is where an author writes one, and
184
+ * it stops at the first line that is none of those, so a directive belonging to the PREVIOUS
185
+ * declaration can never be read as covering this one.
186
+ *
187
+ * Only a directive NAMING this rule counts. An existing `// webpieces-disable no-any-unknown`
188
+ * therefore does not silence these rules, which is the point: that rule answers "is this
189
+ * type-safe?" and these answer "is this field PUBLISHED with no shape?" — different questions
190
+ * with different right answers, so the second one wants its own, separately argued line.
191
+ *
192
+ * A file that cannot be read yields "no disable": a defect is still a defect, and inventing a
193
+ * suppression out of an I/O failure is the one wrong answer.
194
+ */
195
+ // webpieces-disable no-function-outside-class -- static factory of this class
196
+ static readAt(file, line, rule) {
197
+ const lines = DisableComment.linesOf(file);
198
+ if (lines === undefined)
199
+ return undefined;
200
+ const start = Math.max(0, line - 1);
201
+ const onDeclaration = DisableComment.match(lines[start] ?? '', rule);
202
+ if (onDeclaration !== undefined)
203
+ return onDeclaration;
204
+ for (let i = start - 1; i >= 0 && i >= start - DISABLE_LOOKBACK_LINES; i--) {
205
+ const text = (lines[i] ?? '').trim();
206
+ const above = DisableComment.match(text, rule);
207
+ if (above !== undefined)
208
+ return above;
209
+ if (!DisableComment.isTrivia(text))
210
+ return undefined;
211
+ }
212
+ return undefined;
213
+ }
214
+ /** Leading trivia a disable comment is allowed to sit above: blanks, comments and decorators. */
215
+ // webpieces-disable no-function-outside-class -- private static predicate of this class
216
+ static isTrivia(text) {
217
+ return (text === '' ||
218
+ text.startsWith('//') ||
219
+ text.startsWith('*') ||
220
+ text.startsWith('/*') ||
221
+ text.startsWith('@'));
222
+ }
223
+ // webpieces-disable no-function-outside-class -- private static reader of this class
224
+ static match(text, rule) {
225
+ const found = text.trim().match(DISABLE_RE);
226
+ if (found === null)
227
+ return undefined;
228
+ const named = found[1].split(',').map((each) => each.trim());
229
+ if (!named.includes(rule))
230
+ return undefined;
231
+ return new DisableComment((found[2] ?? '').trim() !== '');
232
+ }
233
+ /** The file's lines, or undefined when it cannot be read. */
234
+ // webpieces-disable no-function-outside-class -- private static reader of this class
235
+ static linesOf(file) {
236
+ if (!fs.existsSync(file))
237
+ return undefined;
238
+ return fs.readFileSync(file, 'utf8').split('\n');
239
+ }
240
+ }
241
+ exports.DisableComment = DisableComment;
242
+ //# sourceMappingURL=api-doc-rules.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-doc-rules.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/nx-webpieces-rules/src/lib/api-usage/api-doc-rules.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The DATA and the SWITCHES of `api-rules-for-openapi` and `api-rules-for-mcp` (#1011).\n *\n * The scan itself is in `api-doc-rules-scan.ts`; this file holds what a caller reads back and the\n * per-rule config, so a unit test can construct either without touching a config file and so the\n * config is read exactly once per executor run.\n */\n\nimport * as fs from 'fs';\nimport { loadAndValidate, RULE_NAMES, WEBPIECES_DISABLE } from '@webpieces/rules-config';\nimport { RuleGate } from '../rule-gate';\n\n/** As written in a disable comment and as a config key. */\nexport const OPENAPI_RULE = RULE_NAMES.API_RULES_FOR_OPENAPI;\nexport const MCP_RULE = RULE_NAMES.API_RULES_FOR_MCP;\n\n/** The `@ApiType` value that means \"a partner reads this document\". */\nexport const EXTERNAL_CUSTOMER_API_TYPE = 'external-customer';\n\n/**\n * `// webpieces-disable <rule>[, <rule2>] -- <reason>`, with the reason CAPTURED so a reasonless\n * disable can be told apart from an absent one. Identical to the spelling `root-union-scan` reads.\n */\nconst DISABLE_RE = new RegExp(\n `//\\\\s*${WEBPIECES_DISABLE}\\\\s+([\\\\w-]+(?:\\\\s*,\\\\s*[\\\\w-]+)*)(?:\\\\s*--\\\\s*(.*))?$`,\n);\n\n/** How far above a declaration a disable comment may sit before it is somebody else's comment. */\nconst DISABLE_LOOKBACK_LINES = 40;\n\n/**\n * ONE defect on ONE contract.\n *\n * `exposure` is the contract's `@ApiType` list, and it is carried rather than derived because the\n * SAME defect is louder on a contract that reaches `external-customer`: an unshaped field on a\n * partner document costs a partner something, where the same field on a service-to-service contract\n * costs a colleague a question. The refusal sorts and labels on it.\n */\nexport class ApiContractDefect {\n constructor(\n /** The `@ApiPath` contract class. */\n public readonly api: string,\n /** The `@Endpoint` method, or `''` for a contract-level or DTO-level defect. */\n public readonly method: string,\n /** What is wrong, in one line, naming the declaration. */\n public readonly what: string,\n /** `path/to/File.ts:LINE`, workspace-relative. */\n public readonly at: string,\n /** What to do instead, in one sentence. */\n public readonly cure: string,\n /** The contract's `@ApiType` list — `svc-to-svc` when it declares none. */\n public readonly exposure: readonly string[],\n ) {}\n\n /** True when this contract feeds the PARTNER-facing document. */\n isExternal(): boolean {\n return this.exposure.includes(EXTERNAL_CUSTOMER_API_TYPE);\n }\n\n /** `Api.method` or `Api` — what a reader opens. */\n where(): string {\n return this.method === '' ? this.api : `${this.api}.${this.method}`;\n }\n}\n\n/** What ONE rule found: real defects, and disables of it that gave no reason. */\nexport class ApiRuleFindings {\n constructor(\n public readonly violations: readonly ApiContractDefect[],\n /**\n * Sites that named this rule in a disable and wrote no reason. A reasonless disable is\n * itself a violation: the point of the per-site hatch is the ARGUMENT, which is the only\n * part the next reader can weigh.\n */\n public readonly reasonlessDisables: readonly ApiContractDefect[],\n ) {}\n\n isEmpty(): boolean {\n return this.violations.length === 0 && this.reasonlessDisables.length === 0;\n }\n}\n\n/**\n * ONE endpoint declared PERMANENTLY outside MCP by `@InvalidEndpointForMcp('<reason>')` (#1014).\n *\n * Not a violation and not a suppression — a DECLARATION, which is why it is carried beside the\n * findings rather than in them. `api-rules-for-mcp` restates the whole list, with reasons, on every\n * run: an exclusion announced once at the moment somebody added it is an exclusion nobody will read\n * again, and a build-time warning at add time would be read exactly as little.\n */\nexport class McpExclusion {\n constructor(\n public readonly api: string,\n public readonly method: string,\n /** The decorator's reason, verbatim. Empty only when the argument could not be folded. */\n public readonly reason: string,\n /** `path/to/File.ts:LINE`, workspace-relative. */\n public readonly at: string,\n ) {}\n}\n\n/** Both rules' findings from ONE scan — one program build, two verdicts. */\nexport class ApiDocRulesFindings {\n constructor(\n public readonly openApi: ApiRuleFindings,\n public readonly mcp: ApiRuleFindings,\n /** Every `@InvalidEndpointForMcp` endpoint the MCP rule looked at, in declaration order. */\n public readonly mcpExclusions: readonly McpExclusion[] = [],\n ) {}\n\n // webpieces-disable no-function-outside-class -- static factory of this class\n static empty(): ApiDocRulesFindings {\n return new ApiDocRulesFindings(\n new ApiRuleFindings([], []),\n new ApiRuleFindings([], []),\n [],\n );\n }\n}\n\n/**\n * ONE rule's switches, resolved from webpieces.config.json once per scan.\n *\n * The DEFAULT is OFF, and unlike an absent entry elsewhere that is deliberate: `defaultRules` in\n * `@webpieces/rules-config` carries `mode: 'OFF'` for both, and the argument for it is written\n * there rather than here so there is one place to read it.\n */\nexport class ApiDocRule {\n constructor(\n public readonly name: string,\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 /** ARMED, everywhere — what a unit test constructs, and what an opted-in repo resolves to. */\n // webpieces-disable no-function-outside-class -- static factory of this class\n static armed(name: string): ApiDocRule {\n return new ApiDocRule(name, true, []);\n }\n\n // webpieces-disable no-function-outside-class -- static factory of this class\n static off(name: string): ApiDocRule {\n return new ApiDocRule(name, false, []);\n }\n\n /** `mode: OFF` and the time-box/branch hatches come from RuleGate, so there is ONE reading. */\n // webpieces-disable no-function-outside-class -- static factory of this class\n static fromConfig(workspaceRoot: string, name: string): ApiDocRule {\n if (new RuleGate().isDisabled(workspaceRoot, name, true)) {\n return ApiDocRule.off(name);\n }\n const rule = loadAndValidate(workspaceRoot).resolved.rules.get(name);\n const allowed = rule?.options['allowedPaths'];\n return new ApiDocRule(name, true, Array.isArray(allowed) ? (allowed as string[]) : []);\n }\n}\n\n/** ONE `webpieces-disable` naming a rule, and whether it gave a reason. */\nexport class DisableComment {\n constructor(public readonly hasReason: boolean) {}\n\n /**\n * The disable naming `rule` on the declaration at `line` (1-based) of `file`, or undefined.\n *\n * It walks UPWARD over the declaration's own leading trivia — blank lines, `//` comments, a\n * JSDoc block, and the decorators between them — because that is where an author writes one, and\n * it stops at the first line that is none of those, so a directive belonging to the PREVIOUS\n * declaration can never be read as covering this one.\n *\n * Only a directive NAMING this rule counts. An existing `// webpieces-disable no-any-unknown`\n * therefore does not silence these rules, which is the point: that rule answers \"is this\n * type-safe?\" and these answer \"is this field PUBLISHED with no shape?\" — different questions\n * with different right answers, so the second one wants its own, separately argued line.\n *\n * A file that cannot be read yields \"no disable\": a defect is still a defect, and inventing a\n * suppression out of an I/O failure is the one wrong answer.\n */\n // webpieces-disable no-function-outside-class -- static factory of this class\n static readAt(file: string, line: number, rule: string): DisableComment | undefined {\n const lines = DisableComment.linesOf(file);\n if (lines === undefined) return undefined;\n const start = Math.max(0, line - 1);\n const onDeclaration = DisableComment.match(lines[start] ?? '', rule);\n if (onDeclaration !== undefined) return onDeclaration;\n for (let i = start - 1; i >= 0 && i >= start - DISABLE_LOOKBACK_LINES; i--) {\n const text = (lines[i] ?? '').trim();\n const above = DisableComment.match(text, rule);\n if (above !== undefined) return above;\n if (!DisableComment.isTrivia(text)) return undefined;\n }\n return undefined;\n }\n\n /** Leading trivia a disable comment is allowed to sit above: blanks, comments and decorators. */\n // webpieces-disable no-function-outside-class -- private static predicate of this class\n private static isTrivia(text: string): boolean {\n return (\n text === '' ||\n text.startsWith('//') ||\n text.startsWith('*') ||\n text.startsWith('/*') ||\n text.startsWith('@')\n );\n }\n\n // webpieces-disable no-function-outside-class -- private static reader of this class\n private static match(text: string, rule: string): DisableComment | undefined {\n const found = text.trim().match(DISABLE_RE);\n if (found === null) return undefined;\n const named = found[1].split(',').map((each: string): string => each.trim());\n if (!named.includes(rule)) return undefined;\n return new DisableComment((found[2] ?? '').trim() !== '');\n }\n\n /** The file's lines, or undefined when it cannot be read. */\n // webpieces-disable no-function-outside-class -- private static reader of this class\n private static linesOf(file: string): string[] | undefined {\n if (!fs.existsSync(file)) return undefined;\n return fs.readFileSync(file, 'utf8').split('\\n');\n }\n}\n"]}
@@ -12,7 +12,8 @@
12
12
  */
13
13
  import type { EnhancedGraph } from '../graph-sorter';
14
14
  import { ProjectInfo } from '../project-info';
15
- import { ApiScanResult, UnresolvedApiCall } from './api-scanner';
15
+ import { ApiScanResult } from './api-scanner';
16
+ import { UnresolvedApiCall } from './api-relations';
16
17
  /** One `role:server`/`role:client` project that depends on an api-lib it neither implements nor uses. */
17
18
  export interface UnclassifiedApiDep {
18
19
  project: string;
@@ -1 +1 @@
1
- {"version":3,"file":"api-relations-validator.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/nx-webpieces-rules/src/lib/api-usage/api-relations-validator.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * API Relations Validator\n *\n * Enforces that the dependency graph is TRUTHFUL: a runnable project (role server\n * or client) that compiles against an api-lib must actually IMPLEMENT (serve) or\n * USE (call) at least one of its APIs. A dependency that is neither is almost\n * always a mistake — a forgotten client wiring, a dead import, or a controller\n * that was never registered — and it would draw an unexplained edge in the graph.\n *\n * The check reuses the same source scan that produces `apiRelations`, so \"does P\n * relate to api-lib D\" is answered by real code, never a declaration.\n */\n\nimport type { EnhancedGraph } from '../graph-sorter';\nimport { ProjectInfo } from '../project-info';\nimport { resolveRole } from '../role-resolver';\nimport { ApiScanResult, UnresolvedApiCall } from './api-scanner';\n\n/** Roles that must justify every api-lib dependency (top-level runnables). */\nconst CHECKED_ROLES: ReadonlyArray<string> = ['server', 'client'];\n\n/** One `role:server`/`role:client` project that depends on an api-lib it neither implements nor uses. */\nexport interface UnclassifiedApiDep {\n project: string;\n role: string;\n apiLib: string;\n /** The API contract class names that api-lib exports (for the fix hint). */\n apis: string[];\n /**\n * Contracts this project DOES name at a call site but which never resolved to decorated\n * source. When non-empty the wiring almost certainly exists and the scan is blind — advice\n * to \"add a controller\" or \"remove the dependency\" would be actively wrong.\n */\n unresolved: UnresolvedApiCall[];\n}\n\n/** The API class names owned by `apiLib`, sorted (for a stable fix hint). */\n// webpieces-disable no-function-outside-class -- pure lookup helper, matches the validator-lib style\nfunction apisOwnedBy(scan: ApiScanResult, apiLib: string): string[] {\n const names: string[] = [];\n for (const info of scan.apiIndex.values()) {\n if (info.owner === apiLib) names.push(info.api);\n }\n return names.sort();\n}\n\n/**\n * Every server/client → api-lib edge for which the scan found NO implements and\n * NO uses. `graph` must already carry `apiRelations` (call scanAndAttachApiRelations first).\n */\n// webpieces-disable no-function-outside-class -- module entry point, mirrors findUnclassified-style validators\nexport function findUnclassifiedApiDeps(\n graph: EnhancedGraph,\n projectInfos: Map<string, ProjectInfo>,\n scan: ApiScanResult,\n): UnclassifiedApiDep[] {\n const violations: UnclassifiedApiDep[] = [];\n for (const projectName of Object.keys(graph)) {\n const info = projectInfos.get(projectName);\n if (!info) continue;\n const role = resolveRole(info).role;\n if (role === null || !CHECKED_ROLES.includes(role)) continue;\n // Only flag projects whose production source was actually scanned. An all-test project\n // (e.g. an e2e harness whose only files are *.spec.ts) is never observed, so we can't\n // conclude its api-lib dependency is unused.\n if (!scan.scannedProjects.has(projectName)) continue;\n\n const entry = graph[projectName];\n for (const dep of entry.dependsOn) {\n if (!scan.apiLibProjects.has(dep)) continue;\n if (entry.apiRelations && entry.apiRelations[dep]) continue;\n violations.push({\n project: projectName,\n role,\n apiLib: dep,\n apis: apisOwnedBy(scan, dep),\n unresolved: scan.unresolvedApiCalls.filter((c: UnresolvedApiCall) => c.project === projectName),\n });\n }\n }\n return violations;\n}\n\n/**\n * The project DOES wire this contract up — we just couldn't see it, because the import resolved\n * to a decorator-erased declaration file. Report THAT, not a list of dead ends: telling a dev to\n * register a controller they already registered, or to delete a load-bearing dependency, sends\n * them chasing ghosts and gets the whole validator turned off.\n */\n// webpieces-disable no-function-outside-class -- pure formatter, matches the validator-lib style\nfunction describeBlindScan(violation: UnclassifiedApiDep): string {\n const lines = [\n ` ❌ '${violation.project}' (role:${violation.role}) depends on api-lib '${violation.apiLib}' and its ` +\n `wiring IS present in source, but the contract could not be read:`,\n ];\n for (const call of violation.unresolved) {\n lines.push(\n ` • ${call.api} at ${call.at} resolved to ${call.declaredIn} (decorators erased in .d.ts).`,\n );\n }\n lines.push(\n ` This is a CONFIG gap, not a wiring gap. Do NOT add a controller and do NOT remove the dependency.`,\n ` Fix: add a tsconfig.base.json 'paths' entry for '${violation.apiLib}' → its src/index.ts,`,\n ` so the import resolves to source instead of dist/.`,\n );\n return lines.join('\\n');\n}\n\n/** Human-readable, fix-oriented report for one unclassified dependency. */\n// webpieces-disable no-function-outside-class -- pure formatter, matches the validator-lib style\nexport function describeUnclassifiedApiDep(violation: UnclassifiedApiDep): string {\n if (violation.unresolved.length > 0) return describeBlindScan(violation);\n const apiHint = violation.apis.length > 0 ? violation.apis.join(', ') : 'the API';\n const theApi = violation.apis[0] ?? 'TheApi';\n const lines = [\n ` ❌ '${violation.project}' (role:${violation.role}) depends on api-lib '${violation.apiLib}' ` +\n `but neither IMPLEMENTS nor USES any of its APIs (${apiHint}).`,\n ` Do ONE of:`,\n ` 1. USE it as a client: inject ClientHttpFactory (@webpieces/http-client-node) or ` +\n `ClientHttpBrowserFactory (@webpieces/http-client-browser) and call ` +\n `factory.createRpcClient(${theApi}, config); for a @PubSub api use ` +\n `ClientCloudTasksFactory.createPubSubClient(${theApi}, config).`,\n ` 2. IMPLEMENT it: add a controller and register it — ` +\n `apiFactory.addRoutes(${theApi}, TheController).`,\n ` 3. If the dependency is unused, remove '${violation.apiLib}' from '${violation.project}'.`,\n ];\n return lines.join('\\n');\n}\n"]}
1
+ {"version":3,"file":"api-relations-validator.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/nx-webpieces-rules/src/lib/api-usage/api-relations-validator.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * API Relations Validator\n *\n * Enforces that the dependency graph is TRUTHFUL: a runnable project (role server\n * or client) that compiles against an api-lib must actually IMPLEMENT (serve) or\n * USE (call) at least one of its APIs. A dependency that is neither is almost\n * always a mistake — a forgotten client wiring, a dead import, or a controller\n * that was never registered — and it would draw an unexplained edge in the graph.\n *\n * The check reuses the same source scan that produces `apiRelations`, so \"does P\n * relate to api-lib D\" is answered by real code, never a declaration.\n */\n\nimport type { EnhancedGraph } from '../graph-sorter';\nimport { ProjectInfo } from '../project-info';\nimport { resolveRole } from '../role-resolver';\nimport { ApiScanResult } from './api-scanner';\n// UnresolvedApiCall moved beside its sibling diagnostic DTOs when api-scanner.ts reached its limit.\nimport { UnresolvedApiCall } from './api-relations';\n\n/** Roles that must justify every api-lib dependency (top-level runnables). */\nconst CHECKED_ROLES: ReadonlyArray<string> = ['server', 'client'];\n\n/** One `role:server`/`role:client` project that depends on an api-lib it neither implements nor uses. */\nexport interface UnclassifiedApiDep {\n project: string;\n role: string;\n apiLib: string;\n /** The API contract class names that api-lib exports (for the fix hint). */\n apis: string[];\n /**\n * Contracts this project DOES name at a call site but which never resolved to decorated\n * source. When non-empty the wiring almost certainly exists and the scan is blind — advice\n * to \"add a controller\" or \"remove the dependency\" would be actively wrong.\n */\n unresolved: UnresolvedApiCall[];\n}\n\n/** The API class names owned by `apiLib`, sorted (for a stable fix hint). */\n// webpieces-disable no-function-outside-class -- pure lookup helper, matches the validator-lib style\nfunction apisOwnedBy(scan: ApiScanResult, apiLib: string): string[] {\n const names: string[] = [];\n for (const info of scan.apiIndex.values()) {\n if (info.owner === apiLib) names.push(info.api);\n }\n return names.sort();\n}\n\n/**\n * Every server/client → api-lib edge for which the scan found NO implements and\n * NO uses. `graph` must already carry `apiRelations` (call scanAndAttachApiRelations first).\n */\n// webpieces-disable no-function-outside-class -- module entry point, mirrors findUnclassified-style validators\nexport function findUnclassifiedApiDeps(\n graph: EnhancedGraph,\n projectInfos: Map<string, ProjectInfo>,\n scan: ApiScanResult,\n): UnclassifiedApiDep[] {\n const violations: UnclassifiedApiDep[] = [];\n for (const projectName of Object.keys(graph)) {\n const info = projectInfos.get(projectName);\n if (!info) continue;\n const role = resolveRole(info).role;\n if (role === null || !CHECKED_ROLES.includes(role)) continue;\n // Only flag projects whose production source was actually scanned. An all-test project\n // (e.g. an e2e harness whose only files are *.spec.ts) is never observed, so we can't\n // conclude its api-lib dependency is unused.\n if (!scan.scannedProjects.has(projectName)) continue;\n\n const entry = graph[projectName];\n for (const dep of entry.dependsOn) {\n if (!scan.apiLibProjects.has(dep)) continue;\n if (entry.apiRelations && entry.apiRelations[dep]) continue;\n violations.push({\n project: projectName,\n role,\n apiLib: dep,\n apis: apisOwnedBy(scan, dep),\n unresolved: scan.unresolvedApiCalls.filter((c: UnresolvedApiCall) => c.project === projectName),\n });\n }\n }\n return violations;\n}\n\n/**\n * The project DOES wire this contract up — we just couldn't see it, because the import resolved\n * to a decorator-erased declaration file. Report THAT, not a list of dead ends: telling a dev to\n * register a controller they already registered, or to delete a load-bearing dependency, sends\n * them chasing ghosts and gets the whole validator turned off.\n */\n// webpieces-disable no-function-outside-class -- pure formatter, matches the validator-lib style\nfunction describeBlindScan(violation: UnclassifiedApiDep): string {\n const lines = [\n ` ❌ '${violation.project}' (role:${violation.role}) depends on api-lib '${violation.apiLib}' and its ` +\n `wiring IS present in source, but the contract could not be read:`,\n ];\n for (const call of violation.unresolved) {\n lines.push(\n ` • ${call.api} at ${call.at} resolved to ${call.declaredIn} (decorators erased in .d.ts).`,\n );\n }\n lines.push(\n ` This is a CONFIG gap, not a wiring gap. Do NOT add a controller and do NOT remove the dependency.`,\n ` Fix: add a tsconfig.base.json 'paths' entry for '${violation.apiLib}' → its src/index.ts,`,\n ` so the import resolves to source instead of dist/.`,\n );\n return lines.join('\\n');\n}\n\n/** Human-readable, fix-oriented report for one unclassified dependency. */\n// webpieces-disable no-function-outside-class -- pure formatter, matches the validator-lib style\nexport function describeUnclassifiedApiDep(violation: UnclassifiedApiDep): string {\n if (violation.unresolved.length > 0) return describeBlindScan(violation);\n const apiHint = violation.apis.length > 0 ? violation.apis.join(', ') : 'the API';\n const theApi = violation.apis[0] ?? 'TheApi';\n const lines = [\n ` ❌ '${violation.project}' (role:${violation.role}) depends on api-lib '${violation.apiLib}' ` +\n `but neither IMPLEMENTS nor USES any of its APIs (${apiHint}).`,\n ` Do ONE of:`,\n ` 1. USE it as a client: inject ClientHttpFactory (@webpieces/http-client-node) or ` +\n `ClientHttpBrowserFactory (@webpieces/http-client-browser) and call ` +\n `factory.createRpcClient(${theApi}, config); for a @PubSub api use ` +\n `ClientCloudTasksFactory.createPubSubClient(${theApi}, config).`,\n ` 2. IMPLEMENT it: add a controller and register it — ` +\n `apiFactory.addRoutes(${theApi}, TheController).`,\n ` 3. If the dependency is unused, remove '${violation.apiLib}' from '${violation.project}'.`,\n ];\n return lines.join('\\n');\n}\n"]}
@@ -218,6 +218,31 @@ export interface ApiContract {
218
218
  }
219
219
  /** apiClassName -> its committed contract. Serialized as one `architecture/apis/<ApiName>.json` each. */
220
220
  export type ApiContracts = Record<string, ApiContract>;
221
+ /**
222
+ * An `addRoutes`/`createRpcClient`/`createPubSubClient` first argument that resolved to an
223
+ * abstract class in a DECLARATION file which owns no indexed contract. Unambiguously a broken
224
+ * scan (a real api-lib whose source we never indexed), never a "this isn't an API" argument —
225
+ * so it is reported loudly instead of collapsing into a silent `return null`.
226
+ */
227
+ export declare class UnresolvedApiCall {
228
+ /** The project whose source makes the call. */
229
+ readonly project: string;
230
+ /** The contract class name as written at the call site. */
231
+ readonly api: string;
232
+ /** `path/to/file.ts:LINE` of the call site, workspace-relative. */
233
+ readonly at: string;
234
+ /** The declaration file the checker resolved to (where decorators are erased). */
235
+ readonly declaredIn: string;
236
+ constructor(
237
+ /** The project whose source makes the call. */
238
+ project: string,
239
+ /** The contract class name as written at the call site. */
240
+ api: string,
241
+ /** `path/to/file.ts:LINE` of the call site, workspace-relative. */
242
+ at: string,
243
+ /** The declaration file the checker resolved to (where decorators are erased). */
244
+ declaredIn: string);
245
+ }
221
246
  /**
222
247
  * ONE decorator argument the scan saw but could not reduce to a string — `@ApiPath(SOME_CONST)`
223
248
  * where SOME_CONST is imported from another module, a computed expression, an enum member, ...
@@ -15,7 +15,7 @@
15
15
  * only as binding identifiers, not as member names).
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.EmptiedApiContract = exports.UndeclaredEndpointOperation = exports.UndeclaredExternalCaller = exports.UnresolvedEndpointPath = exports.NonLiteralDecoratorArg = exports.EXTERNAL_SYSTEM_KINDS = void 0;
18
+ exports.EmptiedApiContract = exports.UndeclaredEndpointOperation = exports.UndeclaredExternalCaller = exports.UnresolvedEndpointPath = exports.NonLiteralDecoratorArg = exports.UnresolvedApiCall = exports.EXTERNAL_SYSTEM_KINDS = void 0;
19
19
  exports.isExternalSystemKind = isExternalSystemKind;
20
20
  exports.apiRefKey = apiRefKey;
21
21
  exports.deriveApiRelationKind = deriveApiRelationKind;
@@ -63,6 +63,33 @@ function isExternalSystemKind(value) {
63
63
  function apiRefKey(ref) {
64
64
  return `${ref.api} ${ref.targetService ?? ''}`;
65
65
  }
66
+ /**
67
+ * An `addRoutes`/`createRpcClient`/`createPubSubClient` first argument that resolved to an
68
+ * abstract class in a DECLARATION file which owns no indexed contract. Unambiguously a broken
69
+ * scan (a real api-lib whose source we never indexed), never a "this isn't an API" argument —
70
+ * so it is reported loudly instead of collapsing into a silent `return null`.
71
+ */
72
+ class UnresolvedApiCall {
73
+ project;
74
+ api;
75
+ at;
76
+ declaredIn;
77
+ constructor(
78
+ /** The project whose source makes the call. */
79
+ project,
80
+ /** The contract class name as written at the call site. */
81
+ api,
82
+ /** `path/to/file.ts:LINE` of the call site, workspace-relative. */
83
+ at,
84
+ /** The declaration file the checker resolved to (where decorators are erased). */
85
+ declaredIn) {
86
+ this.project = project;
87
+ this.api = api;
88
+ this.at = at;
89
+ this.declaredIn = declaredIn;
90
+ }
91
+ }
92
+ exports.UnresolvedApiCall = UnresolvedApiCall;
66
93
  /**
67
94
  * ONE decorator argument the scan saw but could not reduce to a string — `@ApiPath(SOME_CONST)`
68
95
  * where SOME_CONST is imported from another module, a computed expression, an enum member, ...
@@ -1 +1 @@
1
- {"version":3,"file":"api-relations.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/nx-webpieces-rules/src/lib/api-usage/api-relations.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * API Relations model\n *\n * The typed classification of a compile-time dependency edge P -> apiLib in\n * architecture/dependencies.json. Where the flat `dependsOn` only says \"P depends\n * on apiLib\", `apiRelations[apiLib]` says WHY: which API contracts P IMPLEMENTS\n * (serves, `class Ctrl extends XxxApi`) and which it USES (calls as a client,\n * `factory.createRpcClient(XxxApi, ...)` / `createPubSubClient(...)`), each tagged\n * with its transport.\n *\n * Interfaces + object literals here mirror the sibling runtime-graph.ts model —\n * these are serialization DTOs written verbatim into the committed JSON, and\n * `implements`/`uses` are legal interface property names (they are reserved words\n * only as binding identifiers, not as member names).\n */\n\n/**\n * Transport of an API contract:\n * - `rpc` — synchronous request/response over HTTP\n * - `pubsub` — fire-and-forget, delivered later through a Cloud Tasks queue\n * - `external` — a contract for a system OUTSIDE this repo (firestore, gmail, ...). Nothing in-repo\n * implements it, so it never becomes a service→service edge; it terminates the graph\n * at a dashed vendor node. Detected from `runtime-architecture.externalApiPaths`\n * rather than from a decorator, because a vendor contract is a plain interface bound\n * to a Symbol token, not an `abstract class` carrying @ApiPath.\n */\nexport type ApiTransport = 'rpc' | 'pubsub' | 'external';\n\n/**\n * The kinds an external system can be DECLARED as. Each draws its own shape in the runtime viz, so\n * a datastore stops looking like an HTTP service.\n *\n * Lives here rather than beside the runtime graph model because that model already imports from this\n * file; putting it there and importing back would close a module cycle.\n */\nexport const EXTERNAL_SYSTEM_KINDS = [\n 'database',\n 'cache',\n 'queue',\n 'storage',\n 'saas',\n 'system',\n /**\n * A destination whose ADDRESS is supplied at runtime — a URL a partner registered, an OAuth\n * callback, a per-tenant host. Unlike every other kind it does not name one vendor: it names the\n * PLACE in our own system where somebody else's address is dialled, which is the fact a security\n * review is looking for.\n *\n * Declared like every other kind, on the CONTRACT: `@externalSystem runtime partner-webhooks`.\n * On the contract rather than at a `createRpcClient` call site deliberately — \"the far end of\n * this contract is outside our estate\" is a property of the CONTRACT, true for every caller of\n * it, so putting it there means one declaration however many services deliver over it, and\n * nothing to keep in step when a second one appears. It also means this kind rides the exact\n * same declare → resolve → draw pipeline `saas` and `database` already ride, rather than a\n * second mechanism that reads construction sites and can disagree with the first.\n */\n 'runtime',\n] as const;\n\nexport type ExternalSystemKind = (typeof EXTERNAL_SYSTEM_KINDS)[number];\n\n/** True for a string that names one of {@link EXTERNAL_SYSTEM_KINDS}. */\n// webpieces-disable no-function-outside-class -- type guard beside the type it guards, matching this file's DTO style\nexport function isExternalSystemKind(value: string): value is ExternalSystemKind {\n return (EXTERNAL_SYSTEM_KINDS as readonly string[]).includes(value);\n}\n\n/**\n * ONE declared external system, keyed in {@link ExternalSystemDecls} by its IDENTITY.\n *\n * Identity, not display text: two projects each tagged `external:database:postgres` name the same\n * `postgres` node and converge on it with one arrow apiece, instead of drawing a database each.\n *\n * The two arrays are the two declaration sites, and a system may legitimately have both — a repo can\n * wrap a datastore behind a contract in one service and open it directly in another.\n */\nexport interface ExternalSystemDecl {\n kind: ExternalSystemKind;\n label: string;\n /** Contracts declaring it with an `@externalSystem` JSDoc tag; every user of one gets an arrow. */\n apis: string[];\n /** Projects declaring it with an `external:<kind>:<identity>` nx tag; each gets its OWN arrow. */\n projects: string[];\n}\n\n/** identity -> its declaration. Serialized as the `externalSystems` key of dependencies.json. */\nexport type ExternalSystemDecls = Record<string, ExternalSystemDecl>;\n\n/**\n * How a project relates to ONE api-lib it depends on:\n * - `implements` — it serves the api (a controller extends it)\n * - `uses` — it calls the api (generates a client)\n * - `uses-implements` — it does BOTH (implements some of the api-lib's contracts,\n * uses others)\n */\nexport type ApiRelationKind = 'implements' | 'uses' | 'uses-implements';\n\n/** One API class a project implements or uses, with its transport. */\nexport interface ApiRef {\n api: string;\n type: ApiTransport;\n /**\n * ONLY on a `uses` ref: the service the call site aims at, read from the client config literal\n * (`createRpcClient(XxxApi, new ClientConfig('helper-fsdb'))` → `helper-fsdb`). It is matched\n * against a project's DECLARED `serviceName` to pick the ONE runtime edge target, instead of\n * fanning the edge out to every implementer of the api — which is catastrophically wrong for a\n * company-wide contract registered in a shared library and therefore implemented by every server.\n *\n * Absent when the config argument is not a `new <Xxx>ClientConfig('<literal>')` (a variable, a\n * computed name, ...). Absent means \"unknown target\", NOT \"no target\" — the runtime graph then\n * falls back to the old fan-out and says so out loud.\n */\n targetService?: string;\n /**\n * ONLY on a `pubsub` uses ref. True means \"this producer was attributed to EVERY cloudtasks\n * method of the contract, not to the methods it actually enqueues\".\n *\n * A producer builds one client for the whole contract (`createPubSubClient(EmailTaskApi, cfg)`)\n * and enqueues through a proxy (`emailTasks.send(req)`) somewhere else entirely — often after\n * the client has been stored in a DI binding — so WHICH methods it enqueues is not statically\n * recoverable. The consumer side IS exact (addRoutes + the contract's method table). Recording\n * the difference keeps a producer-side queue from being read as proof that queue is used.\n */\n methodsInferred?: boolean;\n}\n\n/**\n * Identity of a ref for de-duplication: an api used twice against DIFFERENT services is two distinct\n * relations (two distinct runtime edges), so the api name alone is not the key.\n */\n// webpieces-disable no-function-outside-class -- pure data helper for these serialization DTOs\nexport function apiRefKey(ref: ApiRef): string {\n return `${ref.api} ${ref.targetService ?? ''}`;\n}\n\n/**\n * A project's relationship to ONE api-lib it depends on. Serialized verbatim into\n * architecture/dependencies.json under `apiRelations[apiLibProjectName]`.\n */\nexport interface ApiRelation {\n kind: ApiRelationKind;\n implements: ApiRef[];\n uses: ApiRef[];\n}\n\n/** apiLibProjectName -> relation. Attached to a GraphEntry as `apiRelations`. */\nexport type ProjectApiRelations = Record<string, ApiRelation>;\n\n/**\n * What triggers ONE endpoint, mirroring core-util's `EndpointKind`. Duplicated as a string union\n * rather than imported: nx-webpieces-rules is build tooling and must not take a runtime dependency\n * on the framework it inspects (it reads decorators as TEXT, from projects that may be on a\n * different @webpieces version than the tooling itself).\n */\nexport type EndpointKind = 'rpc' | 'cloudtasks' | 'cron' | 'external';\n\n/** Side-effect semantics copied from the required third `@Endpoint` argument. */\nexport type EndpointOperation = 'read' | 'write-idempotent' | 'write';\n\n/** HTTP verbs understood by the generated contract runtime. */\nexport type ContractHttpMethod = 'GET' | 'POST';\n\n/** One path/query/body mapping emitted into architecture/dependencies.json. */\nexport interface ApiParameterMeta {\n index: number;\n source: 'path' | 'query' | 'body';\n /** Path/query wire name; a JSON/form body occupies the whole entity and has no key. */\n wireName?: string;\n}\n\n/**\n * One method on an API contract, as written in source: what triggers it, where it is mounted, and\n * (for a queued method) which Cloud Tasks queue delivers it.\n */\nexport interface ApiMethodMeta {\n name: string;\n /** The @Endpoint path, relative to the class's @ApiPath basePath. */\n path: string;\n kind: EndpointKind;\n /** Retry/idempotency contract; independent of the HTTP verb. */\n operation: EndpointOperation;\n /** Required in newly scanned source; optional only when reading older committed graph data. */\n httpMethod?: ContractHttpMethod;\n /** Explicit parameter mappings; absent only when the method has none. */\n parameters?: ApiParameterMeta[];\n /** Present when callers receive the transport-neutral full response. */\n responseType?: 'full';\n /**\n * `@Queue(...)` override, else `${ApiClassName}-${methodName}`.\n *\n * ONLY on a `cloudtasks` or `cron` method — those are the kinds actually delivered through a\n * named queue or schedule, and Terraform matches on this string. A synchronous `rpc` (or an\n * inbound `external`) endpoint has no queue and needs none; emitting a plausible-looking name for\n * one put every synchronous endpoint one naive `methods.map(m => m.queueName)` away from being\n * provisioned as a queue.\n */\n queueName?: string;\n /**\n * WHO outside this repo drives this endpoint, from `@Endpoint(POST, p, WRITE, EXTERNAL, { calledBy })`.\n *\n * ONLY on an `external` method, the same way `queueName` is only on the kinds that HAVE a queue.\n *\n * Deliberately the SAME {@link ExternalSystemDeclaration} the OUTBOUND `@externalSystem` tag\n * resolves to, not a parallel inbound-only type: an inbound `saas twilio` and an outbound\n * `saas twilio` are the same vendor, so sharing the type makes them share an IDENTITY and\n * converge on ONE node instead of drawing twilio twice facing opposite directions.\n *\n * Optional in the TYPE only for graphs generated before the caller was required — generation\n * FAILS on an `external` method whose caller cannot be read (UndeclaredExternalCallerError).\n */\n caller?: ExternalSystemDeclaration;\n}\n\n/**\n * A discovered API contract class: its name, the api-lib project that owns it, its transport, and\n * its per-method trigger table.\n */\nexport interface ApiClassInfo {\n api: string;\n owner: string;\n type: ApiTransport;\n /** The class's @ApiPath basePath; absent for an external (vendor) contract, which has no route. */\n basePath?: string;\n /**\n * Every @Endpoint method, in declaration order. Empty for an external contract (a vendor\n * interface has no endpoints — it is called through a vendor SDK, not mounted).\n */\n methods: ApiMethodMeta[];\n /**\n * Set when the contract carries an `@externalSystem <kind> [label]` JSDoc tag — a vendor seam\n * declaring WHAT it is a seam to. JSDoc rather than a decorator because these seams are plain TS\n * `interface`s, which cannot carry one.\n */\n externalSystem?: ExternalSystemDeclaration;\n}\n\n/** The `(kind, label)` pair a single declaration resolves to. */\nexport interface ExternalSystemDeclaration {\n kind: ExternalSystemKind;\n label: string;\n}\n\n/**\n * The committed, per-contract view written to `architecture/apis/<ApiName>.json` (one file per API;\n * dependencies.json only links to it under `apiContractFiles` — see ApiContractFiles).\n *\n * The runtime graph is derived from the committed files so generate and validate can never\n * diverge — which means anything the runtime graph needs must be COMMITTED, not re-scanned.\n * Per-method trigger kinds and queue names are exactly that: without this table the derivation\n * cannot tell a queued endpoint from a cron sweep, and cannot name the queue between two services.\n */\nexport interface ApiContract {\n owner: string;\n /** 'rpc' | 'pubsub' for an in-repo contract, 'external' for a vendor seam. */\n apiKind: ApiTransport;\n /**\n * REQUIRED. Every routed contract carries `@ApiPath`, so every entry in this table must carry the\n * base path its methods hang off. Optional was worse than absent: a consumer joining\n * `basePath + path` for the ONE entry that lost it computed `/test` where the real route was\n * `/whatsapp/test`, and had no reason to suspect it — every other entry had the field. Generation\n * now FAILS instead of shipping an entry that computes a confidently wrong URL.\n */\n basePath: string;\n methods: ApiMethodMeta[];\n}\n\n/** apiClassName -> its committed contract. Serialized as one `architecture/apis/<ApiName>.json` each. */\nexport type ApiContracts = Record<string, ApiContract>;\n\n/**\n * ONE decorator argument the scan saw but could not reduce to a string — `@ApiPath(SOME_CONST)`\n * where SOME_CONST is imported from another module, a computed expression, an enum member, ...\n *\n * Recorded rather than dropped. Before this existed, an unresolvable argument cost the contract its\n * basePath, or a method, or (when EVERY method's path was one) the whole class — with nothing\n * printed anywhere. Same-module constants now resolve, so what remains here is the genuinely\n * unresolvable, which the author can fix by inlining the literal or moving the constant in-module.\n */\nexport class NonLiteralDecoratorArg {\n constructor(\n /** The contract class the argument was written on. */\n public readonly api: string,\n /** `ApiPath` | `Endpoint` | `Queue`. */\n public readonly decorator: string,\n /** The method name for a member decorator, null for a class decorator. */\n public readonly method: string | null,\n /** The argument exactly as written, e.g. `WHATSAPP_API_PATH`. */\n public readonly argument: string,\n /** `path/to/file.ts:LINE`, workspace-relative. */\n public readonly at: string,\n ) {}\n}\n\n/**\n * ONE `@Endpoint(path, kind)` whose PATH argument was present but could not be reduced to a string.\n *\n * Split out of NonLiteralDecoratorArg (which stays a warning, covering @Queue and the rest) because\n * this one is FATAL. Upstream components need the URL: an http client builds its request as\n * `basePath + path`, so an unreadable path is missing ROUTING, not missing metadata — the same\n * reasoning that already makes basePath required. Skipping the method instead used to delete it, and\n * a class whose every path was a constant lost every method and vanished from the contract table with\n * nothing printed anywhere.\n */\nexport class UnresolvedEndpointPath {\n constructor(\n /** The contract class the method is declared on. */\n public readonly api: string,\n /** The method name. */\n public readonly method: string,\n /** The path argument exactly as written, e.g. `PROCESS_PATH`. */\n public readonly argument: string,\n /** `path/to/file.ts:LINE`, workspace-relative. */\n public readonly at: string,\n ) {}\n}\n\n/**\n * ONE `external` `@Endpoint` whose CALLER could not be read from the source.\n *\n * Fatal for the same reason {@link UnresolvedEndpointPath} is. The inbound box exists to say who is\n * calling us from outside; with no caller it can only restate our own contract name, which is the\n * exact bug this diagnostic exists to make impossible to reintroduce. `@Endpoint`'s TS overloads\n * already require `calledBy`, so anything reaching here is a JS caller, an `as any`, a cross-module\n * constant the parser-only scan cannot fold, or an unknown `callerKind`.\n */\nexport class UndeclaredExternalCaller {\n constructor(\n /** The contract class the method is declared on. */\n public readonly api: string,\n /** The method name. */\n public readonly method: string,\n /** What was wrong, as written — `<missing>`, `SOME_CONST`, `callerKind: 'vendor'`. */\n public readonly argument: string,\n /** `path/to/file.ts:LINE`, workspace-relative. */\n public readonly at: string,\n ) {}\n}\n\n/** ONE `@Endpoint` whose required side-effect declaration could not be read from source. */\nexport class UndeclaredEndpointOperation {\n constructor(\n /** The contract class the method is declared on. */\n public readonly api: string,\n /** The method name. */\n public readonly method: string,\n /** What was wrong, as written — `<missing>`, a constant name, or an invalid literal. */\n public readonly argument: string,\n /** `path/to/file.ts:LINE`, workspace-relative. */\n public readonly at: string,\n ) {}\n}\n\n/**\n * A contract class that DECLARED `@Endpoint` methods and kept none of them.\n *\n * The backstop for the mechanism that hid an entire service: `buildApiContracts` skips a zero-method\n * class (correctly — a vendor seam has no routes), so a class gutted by unreadable decorator\n * arguments left through the same door as a legitimately routeless one. A class that declared\n * endpoints and produced none is never legitimate, so it is named instead.\n */\nexport class EmptiedApiContract {\n constructor(\n /** The contract class name. */\n public readonly api: string,\n /** How many `@Endpoint` decorators were written on it. */\n public readonly declared: number,\n /** `path/to/file.ts:LINE` of the class, workspace-relative. */\n public readonly at: string,\n ) {}\n}\n\n/** Derive the relation kind from the (possibly empty) implements/uses ref lists. */\n// webpieces-disable no-function-outside-class -- pure data helper for these serialization DTOs\nexport function deriveApiRelationKind(\n implementsRefs: ApiRef[],\n usesRefs: ApiRef[],\n): ApiRelationKind {\n if (implementsRefs.length > 0 && usesRefs.length > 0) return 'uses-implements';\n if (implementsRefs.length > 0) return 'implements';\n return 'uses';\n}\n\n/**\n * Stable-sort a ref list by api name, then by target service, so the committed JSON is\n * deterministic even when one api is used against two different services.\n */\n// webpieces-disable no-function-outside-class -- pure data helper for these serialization DTOs\nexport function sortApiRefs(refs: ApiRef[]): ApiRef[] {\n return [...refs].sort(\n (a: ApiRef, b: ApiRef) =>\n a.api.localeCompare(b.api) ||\n (a.targetService ?? '').localeCompare(b.targetService ?? ''),\n );\n}\n"]}
1
+ {"version":3,"file":"api-relations.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/nx-webpieces-rules/src/lib/api-usage/api-relations.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * API Relations model\n *\n * The typed classification of a compile-time dependency edge P -> apiLib in\n * architecture/dependencies.json. Where the flat `dependsOn` only says \"P depends\n * on apiLib\", `apiRelations[apiLib]` says WHY: which API contracts P IMPLEMENTS\n * (serves, `class Ctrl extends XxxApi`) and which it USES (calls as a client,\n * `factory.createRpcClient(XxxApi, ...)` / `createPubSubClient(...)`), each tagged\n * with its transport.\n *\n * Interfaces + object literals here mirror the sibling runtime-graph.ts model —\n * these are serialization DTOs written verbatim into the committed JSON, and\n * `implements`/`uses` are legal interface property names (they are reserved words\n * only as binding identifiers, not as member names).\n */\n\n/**\n * Transport of an API contract:\n * - `rpc` — synchronous request/response over HTTP\n * - `pubsub` — fire-and-forget, delivered later through a Cloud Tasks queue\n * - `external` — a contract for a system OUTSIDE this repo (firestore, gmail, ...). Nothing in-repo\n * implements it, so it never becomes a service→service edge; it terminates the graph\n * at a dashed vendor node. Detected from `runtime-architecture.externalApiPaths`\n * rather than from a decorator, because a vendor contract is a plain interface bound\n * to a Symbol token, not an `abstract class` carrying @ApiPath.\n */\nexport type ApiTransport = 'rpc' | 'pubsub' | 'external';\n\n/**\n * The kinds an external system can be DECLARED as. Each draws its own shape in the runtime viz, so\n * a datastore stops looking like an HTTP service.\n *\n * Lives here rather than beside the runtime graph model because that model already imports from this\n * file; putting it there and importing back would close a module cycle.\n */\nexport const EXTERNAL_SYSTEM_KINDS = [\n 'database',\n 'cache',\n 'queue',\n 'storage',\n 'saas',\n 'system',\n /**\n * A destination whose ADDRESS is supplied at runtime — a URL a partner registered, an OAuth\n * callback, a per-tenant host. Unlike every other kind it does not name one vendor: it names the\n * PLACE in our own system where somebody else's address is dialled, which is the fact a security\n * review is looking for.\n *\n * Declared like every other kind, on the CONTRACT: `@externalSystem runtime partner-webhooks`.\n * On the contract rather than at a `createRpcClient` call site deliberately — \"the far end of\n * this contract is outside our estate\" is a property of the CONTRACT, true for every caller of\n * it, so putting it there means one declaration however many services deliver over it, and\n * nothing to keep in step when a second one appears. It also means this kind rides the exact\n * same declare → resolve → draw pipeline `saas` and `database` already ride, rather than a\n * second mechanism that reads construction sites and can disagree with the first.\n */\n 'runtime',\n] as const;\n\nexport type ExternalSystemKind = (typeof EXTERNAL_SYSTEM_KINDS)[number];\n\n/** True for a string that names one of {@link EXTERNAL_SYSTEM_KINDS}. */\n// webpieces-disable no-function-outside-class -- type guard beside the type it guards, matching this file's DTO style\nexport function isExternalSystemKind(value: string): value is ExternalSystemKind {\n return (EXTERNAL_SYSTEM_KINDS as readonly string[]).includes(value);\n}\n\n/**\n * ONE declared external system, keyed in {@link ExternalSystemDecls} by its IDENTITY.\n *\n * Identity, not display text: two projects each tagged `external:database:postgres` name the same\n * `postgres` node and converge on it with one arrow apiece, instead of drawing a database each.\n *\n * The two arrays are the two declaration sites, and a system may legitimately have both — a repo can\n * wrap a datastore behind a contract in one service and open it directly in another.\n */\nexport interface ExternalSystemDecl {\n kind: ExternalSystemKind;\n label: string;\n /** Contracts declaring it with an `@externalSystem` JSDoc tag; every user of one gets an arrow. */\n apis: string[];\n /** Projects declaring it with an `external:<kind>:<identity>` nx tag; each gets its OWN arrow. */\n projects: string[];\n}\n\n/** identity -> its declaration. Serialized as the `externalSystems` key of dependencies.json. */\nexport type ExternalSystemDecls = Record<string, ExternalSystemDecl>;\n\n/**\n * How a project relates to ONE api-lib it depends on:\n * - `implements` — it serves the api (a controller extends it)\n * - `uses` — it calls the api (generates a client)\n * - `uses-implements` — it does BOTH (implements some of the api-lib's contracts,\n * uses others)\n */\nexport type ApiRelationKind = 'implements' | 'uses' | 'uses-implements';\n\n/** One API class a project implements or uses, with its transport. */\nexport interface ApiRef {\n api: string;\n type: ApiTransport;\n /**\n * ONLY on a `uses` ref: the service the call site aims at, read from the client config literal\n * (`createRpcClient(XxxApi, new ClientConfig('helper-fsdb'))` → `helper-fsdb`). It is matched\n * against a project's DECLARED `serviceName` to pick the ONE runtime edge target, instead of\n * fanning the edge out to every implementer of the api — which is catastrophically wrong for a\n * company-wide contract registered in a shared library and therefore implemented by every server.\n *\n * Absent when the config argument is not a `new <Xxx>ClientConfig('<literal>')` (a variable, a\n * computed name, ...). Absent means \"unknown target\", NOT \"no target\" — the runtime graph then\n * falls back to the old fan-out and says so out loud.\n */\n targetService?: string;\n /**\n * ONLY on a `pubsub` uses ref. True means \"this producer was attributed to EVERY cloudtasks\n * method of the contract, not to the methods it actually enqueues\".\n *\n * A producer builds one client for the whole contract (`createPubSubClient(EmailTaskApi, cfg)`)\n * and enqueues through a proxy (`emailTasks.send(req)`) somewhere else entirely — often after\n * the client has been stored in a DI binding — so WHICH methods it enqueues is not statically\n * recoverable. The consumer side IS exact (addRoutes + the contract's method table). Recording\n * the difference keeps a producer-side queue from being read as proof that queue is used.\n */\n methodsInferred?: boolean;\n}\n\n/**\n * Identity of a ref for de-duplication: an api used twice against DIFFERENT services is two distinct\n * relations (two distinct runtime edges), so the api name alone is not the key.\n */\n// webpieces-disable no-function-outside-class -- pure data helper for these serialization DTOs\nexport function apiRefKey(ref: ApiRef): string {\n return `${ref.api} ${ref.targetService ?? ''}`;\n}\n\n/**\n * A project's relationship to ONE api-lib it depends on. Serialized verbatim into\n * architecture/dependencies.json under `apiRelations[apiLibProjectName]`.\n */\nexport interface ApiRelation {\n kind: ApiRelationKind;\n implements: ApiRef[];\n uses: ApiRef[];\n}\n\n/** apiLibProjectName -> relation. Attached to a GraphEntry as `apiRelations`. */\nexport type ProjectApiRelations = Record<string, ApiRelation>;\n\n/**\n * What triggers ONE endpoint, mirroring core-util's `EndpointKind`. Duplicated as a string union\n * rather than imported: nx-webpieces-rules is build tooling and must not take a runtime dependency\n * on the framework it inspects (it reads decorators as TEXT, from projects that may be on a\n * different @webpieces version than the tooling itself).\n */\nexport type EndpointKind = 'rpc' | 'cloudtasks' | 'cron' | 'external';\n\n/** Side-effect semantics copied from the required third `@Endpoint` argument. */\nexport type EndpointOperation = 'read' | 'write-idempotent' | 'write';\n\n/** HTTP verbs understood by the generated contract runtime. */\nexport type ContractHttpMethod = 'GET' | 'POST';\n\n/** One path/query/body mapping emitted into architecture/dependencies.json. */\nexport interface ApiParameterMeta {\n index: number;\n source: 'path' | 'query' | 'body';\n /** Path/query wire name; a JSON/form body occupies the whole entity and has no key. */\n wireName?: string;\n}\n\n/**\n * One method on an API contract, as written in source: what triggers it, where it is mounted, and\n * (for a queued method) which Cloud Tasks queue delivers it.\n */\nexport interface ApiMethodMeta {\n name: string;\n /** The @Endpoint path, relative to the class's @ApiPath basePath. */\n path: string;\n kind: EndpointKind;\n /** Retry/idempotency contract; independent of the HTTP verb. */\n operation: EndpointOperation;\n /** Required in newly scanned source; optional only when reading older committed graph data. */\n httpMethod?: ContractHttpMethod;\n /** Explicit parameter mappings; absent only when the method has none. */\n parameters?: ApiParameterMeta[];\n /** Present when callers receive the transport-neutral full response. */\n responseType?: 'full';\n /**\n * `@Queue(...)` override, else `${ApiClassName}-${methodName}`.\n *\n * ONLY on a `cloudtasks` or `cron` method — those are the kinds actually delivered through a\n * named queue or schedule, and Terraform matches on this string. A synchronous `rpc` (or an\n * inbound `external`) endpoint has no queue and needs none; emitting a plausible-looking name for\n * one put every synchronous endpoint one naive `methods.map(m => m.queueName)` away from being\n * provisioned as a queue.\n */\n queueName?: string;\n /**\n * WHO outside this repo drives this endpoint, from `@Endpoint(POST, p, WRITE, EXTERNAL, { calledBy })`.\n *\n * ONLY on an `external` method, the same way `queueName` is only on the kinds that HAVE a queue.\n *\n * Deliberately the SAME {@link ExternalSystemDeclaration} the OUTBOUND `@externalSystem` tag\n * resolves to, not a parallel inbound-only type: an inbound `saas twilio` and an outbound\n * `saas twilio` are the same vendor, so sharing the type makes them share an IDENTITY and\n * converge on ONE node instead of drawing twilio twice facing opposite directions.\n *\n * Optional in the TYPE only for graphs generated before the caller was required — generation\n * FAILS on an `external` method whose caller cannot be read (UndeclaredExternalCallerError).\n */\n caller?: ExternalSystemDeclaration;\n}\n\n/**\n * A discovered API contract class: its name, the api-lib project that owns it, its transport, and\n * its per-method trigger table.\n */\nexport interface ApiClassInfo {\n api: string;\n owner: string;\n type: ApiTransport;\n /** The class's @ApiPath basePath; absent for an external (vendor) contract, which has no route. */\n basePath?: string;\n /**\n * Every @Endpoint method, in declaration order. Empty for an external contract (a vendor\n * interface has no endpoints — it is called through a vendor SDK, not mounted).\n */\n methods: ApiMethodMeta[];\n /**\n * Set when the contract carries an `@externalSystem <kind> [label]` JSDoc tag — a vendor seam\n * declaring WHAT it is a seam to. JSDoc rather than a decorator because these seams are plain TS\n * `interface`s, which cannot carry one.\n */\n externalSystem?: ExternalSystemDeclaration;\n}\n\n/** The `(kind, label)` pair a single declaration resolves to. */\nexport interface ExternalSystemDeclaration {\n kind: ExternalSystemKind;\n label: string;\n}\n\n/**\n * The committed, per-contract view written to `architecture/apis/<ApiName>.json` (one file per API;\n * dependencies.json only links to it under `apiContractFiles` — see ApiContractFiles).\n *\n * The runtime graph is derived from the committed files so generate and validate can never\n * diverge — which means anything the runtime graph needs must be COMMITTED, not re-scanned.\n * Per-method trigger kinds and queue names are exactly that: without this table the derivation\n * cannot tell a queued endpoint from a cron sweep, and cannot name the queue between two services.\n */\nexport interface ApiContract {\n owner: string;\n /** 'rpc' | 'pubsub' for an in-repo contract, 'external' for a vendor seam. */\n apiKind: ApiTransport;\n /**\n * REQUIRED. Every routed contract carries `@ApiPath`, so every entry in this table must carry the\n * base path its methods hang off. Optional was worse than absent: a consumer joining\n * `basePath + path` for the ONE entry that lost it computed `/test` where the real route was\n * `/whatsapp/test`, and had no reason to suspect it — every other entry had the field. Generation\n * now FAILS instead of shipping an entry that computes a confidently wrong URL.\n */\n basePath: string;\n methods: ApiMethodMeta[];\n}\n\n/** apiClassName -> its committed contract. Serialized as one `architecture/apis/<ApiName>.json` each. */\nexport type ApiContracts = Record<string, ApiContract>;\n\n/**\n * An `addRoutes`/`createRpcClient`/`createPubSubClient` first argument that resolved to an\n * abstract class in a DECLARATION file which owns no indexed contract. Unambiguously a broken\n * scan (a real api-lib whose source we never indexed), never a \"this isn't an API\" argument —\n * so it is reported loudly instead of collapsing into a silent `return null`.\n */\nexport class UnresolvedApiCall {\n constructor(\n /** The project whose source makes the call. */\n public readonly project: string,\n /** The contract class name as written at the call site. */\n public readonly api: string,\n /** `path/to/file.ts:LINE` of the call site, workspace-relative. */\n public readonly at: string,\n /** The declaration file the checker resolved to (where decorators are erased). */\n public readonly declaredIn: string,\n ) {}\n}\n\n/**\n * ONE decorator argument the scan saw but could not reduce to a string — `@ApiPath(SOME_CONST)`\n * where SOME_CONST is imported from another module, a computed expression, an enum member, ...\n *\n * Recorded rather than dropped. Before this existed, an unresolvable argument cost the contract its\n * basePath, or a method, or (when EVERY method's path was one) the whole class — with nothing\n * printed anywhere. Same-module constants now resolve, so what remains here is the genuinely\n * unresolvable, which the author can fix by inlining the literal or moving the constant in-module.\n */\nexport class NonLiteralDecoratorArg {\n constructor(\n /** The contract class the argument was written on. */\n public readonly api: string,\n /** `ApiPath` | `Endpoint` | `Queue`. */\n public readonly decorator: string,\n /** The method name for a member decorator, null for a class decorator. */\n public readonly method: string | null,\n /** The argument exactly as written, e.g. `WHATSAPP_API_PATH`. */\n public readonly argument: string,\n /** `path/to/file.ts:LINE`, workspace-relative. */\n public readonly at: string,\n ) {}\n}\n\n/**\n * ONE `@Endpoint(path, kind)` whose PATH argument was present but could not be reduced to a string.\n *\n * Split out of NonLiteralDecoratorArg (which stays a warning, covering @Queue and the rest) because\n * this one is FATAL. Upstream components need the URL: an http client builds its request as\n * `basePath + path`, so an unreadable path is missing ROUTING, not missing metadata — the same\n * reasoning that already makes basePath required. Skipping the method instead used to delete it, and\n * a class whose every path was a constant lost every method and vanished from the contract table with\n * nothing printed anywhere.\n */\nexport class UnresolvedEndpointPath {\n constructor(\n /** The contract class the method is declared on. */\n public readonly api: string,\n /** The method name. */\n public readonly method: string,\n /** The path argument exactly as written, e.g. `PROCESS_PATH`. */\n public readonly argument: string,\n /** `path/to/file.ts:LINE`, workspace-relative. */\n public readonly at: string,\n ) {}\n}\n\n/**\n * ONE `external` `@Endpoint` whose CALLER could not be read from the source.\n *\n * Fatal for the same reason {@link UnresolvedEndpointPath} is. The inbound box exists to say who is\n * calling us from outside; with no caller it can only restate our own contract name, which is the\n * exact bug this diagnostic exists to make impossible to reintroduce. `@Endpoint`'s TS overloads\n * already require `calledBy`, so anything reaching here is a JS caller, an `as any`, a cross-module\n * constant the parser-only scan cannot fold, or an unknown `callerKind`.\n */\nexport class UndeclaredExternalCaller {\n constructor(\n /** The contract class the method is declared on. */\n public readonly api: string,\n /** The method name. */\n public readonly method: string,\n /** What was wrong, as written — `<missing>`, `SOME_CONST`, `callerKind: 'vendor'`. */\n public readonly argument: string,\n /** `path/to/file.ts:LINE`, workspace-relative. */\n public readonly at: string,\n ) {}\n}\n\n/** ONE `@Endpoint` whose required side-effect declaration could not be read from source. */\nexport class UndeclaredEndpointOperation {\n constructor(\n /** The contract class the method is declared on. */\n public readonly api: string,\n /** The method name. */\n public readonly method: string,\n /** What was wrong, as written — `<missing>`, a constant name, or an invalid literal. */\n public readonly argument: string,\n /** `path/to/file.ts:LINE`, workspace-relative. */\n public readonly at: string,\n ) {}\n}\n\n/**\n * A contract class that DECLARED `@Endpoint` methods and kept none of them.\n *\n * The backstop for the mechanism that hid an entire service: `buildApiContracts` skips a zero-method\n * class (correctly — a vendor seam has no routes), so a class gutted by unreadable decorator\n * arguments left through the same door as a legitimately routeless one. A class that declared\n * endpoints and produced none is never legitimate, so it is named instead.\n */\nexport class EmptiedApiContract {\n constructor(\n /** The contract class name. */\n public readonly api: string,\n /** How many `@Endpoint` decorators were written on it. */\n public readonly declared: number,\n /** `path/to/file.ts:LINE` of the class, workspace-relative. */\n public readonly at: string,\n ) {}\n}\n\n/** Derive the relation kind from the (possibly empty) implements/uses ref lists. */\n// webpieces-disable no-function-outside-class -- pure data helper for these serialization DTOs\nexport function deriveApiRelationKind(\n implementsRefs: ApiRef[],\n usesRefs: ApiRef[],\n): ApiRelationKind {\n if (implementsRefs.length > 0 && usesRefs.length > 0) return 'uses-implements';\n if (implementsRefs.length > 0) return 'implements';\n return 'uses';\n}\n\n/**\n * Stable-sort a ref list by api name, then by target service, so the committed JSON is\n * deterministic even when one api is used against two different services.\n */\n// webpieces-disable no-function-outside-class -- pure data helper for these serialization DTOs\nexport function sortApiRefs(refs: ApiRef[]): ApiRef[] {\n return [...refs].sort(\n (a: ApiRef, b: ApiRef) =>\n a.api.localeCompare(b.api) ||\n (a.targetService ?? '').localeCompare(b.targetService ?? ''),\n );\n}\n"]}
@@ -29,32 +29,9 @@
29
29
  */
30
30
  import type { EnhancedGraph } from '../graph-sorter';
31
31
  import { ProjectInfo } from '../project-info';
32
- import { ApiClassInfo, ApiContracts, EmptiedApiContract, NonLiteralDecoratorArg, ProjectApiRelations, UndeclaredExternalCaller, UndeclaredEndpointOperation, UnresolvedEndpointPath } from './api-relations';
33
- /**
34
- * An `addRoutes`/`createRpcClient`/`createPubSubClient` first argument that resolved to an
35
- * abstract class in a DECLARATION file which owns no indexed contract. Unambiguously a broken
36
- * scan (a real api-lib whose source we never indexed), never a "this isn't an API" argument —
37
- * so it is reported loudly instead of collapsing into a silent `return null`.
38
- */
39
- export declare class UnresolvedApiCall {
40
- /** The project whose source makes the call. */
41
- readonly project: string;
42
- /** The contract class name as written at the call site. */
43
- readonly api: string;
44
- /** `path/to/file.ts:LINE` of the call site, workspace-relative. */
45
- readonly at: string;
46
- /** The declaration file the checker resolved to (where decorators are erased). */
47
- readonly declaredIn: string;
48
- constructor(
49
- /** The project whose source makes the call. */
50
- project: string,
51
- /** The contract class name as written at the call site. */
52
- api: string,
53
- /** `path/to/file.ts:LINE` of the call site, workspace-relative. */
54
- at: string,
55
- /** The declaration file the checker resolved to (where decorators are erased). */
56
- declaredIn: string);
57
- }
32
+ import { ApiClassInfo, ApiContracts, EmptiedApiContract, NonLiteralDecoratorArg, ProjectApiRelations, UndeclaredExternalCaller, UndeclaredEndpointOperation, UnresolvedApiCall, UnresolvedEndpointPath } from './api-relations';
33
+ import { RootUnionFindings, RootUnionRule } from './root-union-scan';
34
+ import { ApiDocRule, ApiDocRulesFindings } from './api-doc-rules';
58
35
  /** The whole-workspace result of a scan. */
59
36
  export interface ApiScanResult {
60
37
  /** projectName -> { apiLibProject -> relation }; only projects with ≥1 relation appear. */
@@ -98,6 +75,13 @@ export interface ApiScanResult {
98
75
  undeclaredExternalCallers: UndeclaredExternalCaller[];
99
76
  /** Endpoints lacking the explicit side-effect contract used for retry safety and MCP hints. */
100
77
  undeclaredEndpointOperations: UndeclaredEndpointOperation[];
78
+ /** `no-root-union-api-type`'s findings. Fatal in buildApiContracts — see root-union-scan.ts. */
79
+ rootUnions: RootUnionFindings;
80
+ /**
81
+ * `api-rules-for-openapi` / `api-rules-for-mcp` findings (#1011). Both ship OFF, so this is
82
+ * EMPTY unless a repo opted in — see `defaultRules` in `@webpieces/rules-config` for why.
83
+ */
84
+ apiDocRules: ApiDocRulesFindings;
101
85
  }
102
86
  /** Statically scans every project for its api-lib implements/uses relationships. */
103
87
  export declare class ApiUsageScanner {
@@ -105,6 +89,12 @@ export declare class ApiUsageScanner {
105
89
  private readonly projectInfos;
106
90
  /** Globs of project roots whose exported `*Api` types are contracts for outside systems. */
107
91
  private readonly externalApiPaths;
92
+ /** `no-root-union-api-type`'s switches — ARMED unless scanAndAttachApiRelations read otherwise. */
93
+ private readonly rootUnionRule;
94
+ /** `api-rules-for-openapi`'s switches — OFF unless a repo opted in. */
95
+ private readonly openApiRule;
96
+ /** `api-rules-for-mcp`'s switches — OFF unless a repo opted in. */
97
+ private readonly mcpRule;
108
98
  private readonly locator;
109
99
  private readonly relationsByProject;
110
100
  private readonly scannedProjects;
@@ -113,7 +103,13 @@ export declare class ApiUsageScanner {
113
103
  private sourceIndex;
114
104
  constructor(workspaceRoot: string, projectInfos: Map<string, ProjectInfo>,
115
105
  /** Globs of project roots whose exported `*Api` types are contracts for outside systems. */
116
- externalApiPaths?: readonly string[]);
106
+ externalApiPaths?: readonly string[],
107
+ /** `no-root-union-api-type`'s switches — ARMED unless scanAndAttachApiRelations read otherwise. */
108
+ rootUnionRule?: RootUnionRule,
109
+ /** `api-rules-for-openapi`'s switches — OFF unless a repo opted in. */
110
+ openApiRule?: ApiDocRule,
111
+ /** `api-rules-for-mcp`'s switches — OFF unless a repo opted in. */
112
+ mcpRule?: ApiDocRule);
117
113
  scan(): ApiScanResult;
118
114
  private scanProject;
119
115
  private visit;
@@ -198,9 +194,3 @@ export declare function describeNonLiteralDecoratorArgs(args: readonly NonLitera
198
194
  * ENDPOINT_KINDS_BY_API_KIND at BUILD time, where it can name the file instead of throwing at wiring.
199
195
  */
200
196
  export declare function describeMismatchedEndpointKinds(contracts: ApiContracts): string[];
201
- /**
202
- * Loud, actionable report for contracts the scan could not map to source. Callers print this
203
- * instead of emitting a green graph that is quietly missing relations. Not fatal: a contract
204
- * from a genuinely EXTERNAL (published, non-workspace) api-lib legitimately has no source here.
205
- */
206
- export declare function describeUnresolvedApiCalls(calls: UnresolvedApiCall[]): string;