@webpieces/nx-webpieces-rules 0.4.486 → 0.4.488

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.
Files changed (35) hide show
  1. package/package.json +6 -6
  2. package/src/executors/generate/executor.js +42 -19
  3. package/src/executors/generate/executor.js.map +1 -1
  4. package/src/executors/validate-api-relations/executor.js +6 -1
  5. package/src/executors/validate-api-relations/executor.js.map +1 -1
  6. package/src/executors/validate-architecture-unchanged/executor.js +65 -15
  7. package/src/executors/validate-architecture-unchanged/executor.js.map +1 -1
  8. package/src/executors/validate-runtime-architecture/executor.js +3 -1
  9. package/src/executors/validate-runtime-architecture/executor.js.map +1 -1
  10. package/src/lib/api-usage/api-ast.d.ts +71 -0
  11. package/src/lib/api-usage/api-ast.js +250 -0
  12. package/src/lib/api-usage/api-ast.js.map +1 -0
  13. package/src/lib/api-usage/api-relations.d.ts +72 -3
  14. package/src/lib/api-usage/api-relations.js.map +1 -1
  15. package/src/lib/api-usage/api-scanner.d.ts +43 -4
  16. package/src/lib/api-usage/api-scanner.js +121 -103
  17. package/src/lib/api-usage/api-scanner.js.map +1 -1
  18. package/src/lib/graph-loader.d.ts +21 -2
  19. package/src/lib/graph-loader.js +38 -4
  20. package/src/lib/graph-loader.js.map +1 -1
  21. package/src/lib/runtime-config.d.ts +7 -1
  22. package/src/lib/runtime-config.js +7 -1
  23. package/src/lib/runtime-config.js.map +1 -1
  24. package/src/lib/runtime-graph-io.d.ts +17 -0
  25. package/src/lib/runtime-graph-io.js +59 -0
  26. package/src/lib/runtime-graph-io.js.map +1 -0
  27. package/src/lib/runtime-graph-model.d.ts +121 -0
  28. package/src/lib/runtime-graph-model.js +14 -0
  29. package/src/lib/runtime-graph-model.js.map +1 -0
  30. package/src/lib/runtime-graph.d.ts +6 -75
  31. package/src/lib/runtime-graph.js +163 -51
  32. package/src/lib/runtime-graph.js.map +1 -1
  33. package/src/lib/runtime-visualizer.d.ts +5 -0
  34. package/src/lib/runtime-visualizer.js +63 -6
  35. package/src/lib/runtime-visualizer.js.map +1 -1
@@ -0,0 +1,250 @@
1
+ "use strict";
2
+ /**
3
+ * API contract AST accessors
4
+ *
5
+ * The pure, stateless half of the api scan: given a TypeScript node, what contract / endpoint /
6
+ * injected type does it describe? Split out of api-scanner.ts, which owns the STATEFUL walk (project
7
+ * programs, the source index, relation accumulation) and had grown past the file-size limit.
8
+ *
9
+ * Everything here is parser-level on purpose. Decorators must be read exactly as written, and a
10
+ * plain parse cannot be diverted to a decorator-erased `.d.ts` by module resolution — the bug
11
+ * api-scanner's source pre-pass exists to guard against.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.apiClassInfoFrom = apiClassInfoFrom;
15
+ exports.apiTransport = apiTransport;
16
+ exports.endpointMethodsOf = endpointMethodsOf;
17
+ exports.decoratorArgs = decoratorArgs;
18
+ exports.memberDecorator = memberDecorator;
19
+ exports.decoratorStringArg = decoratorStringArg;
20
+ exports.constructorParamsOf = constructorParamsOf;
21
+ exports.typeReferenceName = typeReferenceName;
22
+ exports.implementedTypeNames = implementedTypeNames;
23
+ exports.isAbstractClass = isAbstractClass;
24
+ exports.hasClassDecorator = hasClassDecorator;
25
+ exports.targetServiceOf = targetServiceOf;
26
+ exports.calleeMethodName = calleeMethodName;
27
+ exports.isTestFile = isTestFile;
28
+ exports.apiClassInfoFromNode = apiClassInfoFromNode;
29
+ exports.externalApiInfoFrom = externalApiInfoFrom;
30
+ exports.isExported = isExported;
31
+ exports.collectTsFiles = collectTsFiles;
32
+ const tslib_1 = require("tslib");
33
+ const ts = tslib_1.__importStar(require("typescript"));
34
+ const fs = tslib_1.__importStar(require("fs"));
35
+ const path = tslib_1.__importStar(require("path"));
36
+ const bindings_1 = require("../di-graph/bindings");
37
+ /** Legal `@Endpoint(path, kind)` values; anything else is a source error, not a kind we invent. */
38
+ const ENDPOINT_KINDS = ['rpc', 'cloudtasks', 'cron', 'external'];
39
+ /**
40
+ * Name suffix that marks an exported type in an `externalApiPaths` project as a vendor CONTRACT
41
+ * (`GmailApi`, `StorageApi`) rather than one of the DTOs, configs or clients sitting beside it.
42
+ * The same convention the in-repo contracts already follow, applied where no decorator can be read.
43
+ */
44
+ const EXTERNAL_CONTRACT_SUFFIX = 'Api';
45
+ /**
46
+ * Client-config class-name suffix whose FIRST constructor argument is the target service name —
47
+ * `ClientConfig('helper-fsdb')` (rpc) and `TaskClientConfig('helper-fsdb')` (pubsub) both take
48
+ * `svcName` first, and a consumer's own `XxxClientConfig` follows the same shape.
49
+ */
50
+ const CLIENT_CONFIG_SUFFIX = 'ClientConfig';
51
+ /** {api, owner: `project`, type} when `cls` is an `abstract class` carrying `@ApiPath`, else null. */
52
+ // webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts
53
+ function apiClassInfoFrom(cls, project) {
54
+ if (!isAbstractClass(cls) || !hasClassDecorator(cls, 'ApiPath') || !cls.name)
55
+ return null;
56
+ const api = cls.name.text;
57
+ const info = {
58
+ api,
59
+ owner: project,
60
+ type: apiTransport(cls),
61
+ methods: endpointMethodsOf(cls, api),
62
+ };
63
+ const basePath = decoratorStringArg(cls, 'ApiPath');
64
+ if (basePath !== null)
65
+ info.basePath = basePath;
66
+ return info;
67
+ }
68
+ // webpieces-disable no-function-outside-class -- pure AST predicate, matching the sibling helpers in di-graph/bindings.ts
69
+ function apiTransport(cls) {
70
+ return hasClassDecorator(cls, 'PubSub') ? 'pubsub' : 'rpc';
71
+ }
72
+ /**
73
+ * Every `@Endpoint(path, kind)` method on a contract class, in declaration order.
74
+ *
75
+ * `kind` is a REQUIRED argument of the decorator, so a missing/non-literal second argument means the
76
+ * source does not compile (or is mid-edit) — we skip the method rather than defaulting it. Defaulting
77
+ * would put an undeclared cron or webhook into the graph as an ordinary rpc call, which is precisely
78
+ * the blindness the required argument exists to remove.
79
+ */
80
+ // webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts
81
+ function endpointMethodsOf(cls, api) {
82
+ const methods = [];
83
+ for (const member of cls.members) {
84
+ if (!ts.isMethodDeclaration(member) || !ts.isIdentifier(member.name))
85
+ continue;
86
+ const endpoint = memberDecorator(member, 'Endpoint');
87
+ if (endpoint === null)
88
+ continue;
89
+ const args = decoratorArgs(endpoint);
90
+ const path = args[0] !== undefined && ts.isStringLiteral(args[0]) ? args[0].text : null;
91
+ const kind = args[1] !== undefined && ts.isStringLiteral(args[1]) ? args[1].text : null;
92
+ if (path === null || kind === null || !ENDPOINT_KINDS.includes(kind))
93
+ continue;
94
+ const name = member.name.text;
95
+ const override = memberDecorator(member, 'Queue');
96
+ const queueArg = override === null ? undefined : decoratorArgs(override)[0];
97
+ const queueName = queueArg !== undefined && ts.isStringLiteral(queueArg) ? queueArg.text : `${api}-${name}`;
98
+ methods.push({ name, path, kind: kind, queueName });
99
+ }
100
+ return methods;
101
+ }
102
+ /** The arguments of a decorator's call expression, or [] when it is a bare `@Foo` reference. */
103
+ // webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts
104
+ function decoratorArgs(decorator) {
105
+ return ts.isCallExpression(decorator.expression) ? decorator.expression.arguments : [];
106
+ }
107
+ /** The named decorator on a class member, or null. */
108
+ // webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts
109
+ function memberDecorator(member, name) {
110
+ const decorators = ts.getDecorators(member) ?? [];
111
+ return decorators.find((d) => (0, bindings_1.decoratorName)(d) === name) ?? null;
112
+ }
113
+ /** The first argument of a class decorator when it is a string literal (`@ApiPath('/x')`), else null. */
114
+ // webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts
115
+ function decoratorStringArg(cls, name) {
116
+ const decorator = (0, bindings_1.classDecorators)(cls).find((d) => (0, bindings_1.decoratorName)(d) === name);
117
+ if (decorator === undefined)
118
+ return null;
119
+ const first = decoratorArgs(decorator)[0];
120
+ return first !== undefined && ts.isStringLiteral(first) ? first.text : null;
121
+ }
122
+ /** The constructor's parameters, or [] when the class declares no constructor. */
123
+ // webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts
124
+ function constructorParamsOf(cls) {
125
+ for (const member of cls.members) {
126
+ if (ts.isConstructorDeclaration(member))
127
+ return member.parameters;
128
+ }
129
+ return [];
130
+ }
131
+ /**
132
+ * The bare name of a type reference (`GmailApi`, or `gmail.GmailApi` -> `GmailApi`), else null.
133
+ * Generic wrappers are deliberately NOT unwrapped: `Provider<GmailApi>` hands out the contract
134
+ * lazily, which is still a use, but it is not the shape any of these seams take today and guessing
135
+ * at type arguments would start matching things that merely mention a contract.
136
+ */
137
+ // webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts
138
+ function typeReferenceName(type) {
139
+ if (type === undefined || !ts.isTypeReferenceNode(type))
140
+ return null;
141
+ const name = type.typeName;
142
+ if (ts.isIdentifier(name))
143
+ return name.text;
144
+ return ts.isQualifiedName(name) && ts.isIdentifier(name.right) ? name.right.text : null;
145
+ }
146
+ /** Every type name in the class's `implements` clause — the contracts this class IS, not ones it calls. */
147
+ // webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts
148
+ function implementedTypeNames(cls) {
149
+ const names = new Set();
150
+ for (const clause of cls.heritageClauses ?? []) {
151
+ if (clause.token !== ts.SyntaxKind.ImplementsKeyword)
152
+ continue;
153
+ for (const type of clause.types) {
154
+ if (ts.isIdentifier(type.expression))
155
+ names.add(type.expression.text);
156
+ }
157
+ }
158
+ return names;
159
+ }
160
+ // webpieces-disable no-function-outside-class -- pure AST predicate, matching the sibling helpers in di-graph/bindings.ts
161
+ function isAbstractClass(cls) {
162
+ return (ts.getModifiers(cls) ?? []).some((m) => m.kind === ts.SyntaxKind.AbstractKeyword);
163
+ }
164
+ // webpieces-disable no-function-outside-class -- pure AST predicate, matching the sibling helpers in di-graph/bindings.ts
165
+ function hasClassDecorator(cls, name) {
166
+ return (0, bindings_1.classDecorators)(cls).some((d) => (0, bindings_1.decoratorName)(d) === name);
167
+ }
168
+ /**
169
+ * The service a client-factory call aims at, from its config argument:
170
+ * `createRpcClient(WarmupApi, new ClientConfig('helper-fsdb'))` → `'helper-fsdb'`.
171
+ *
172
+ * Only a `new <Xxx>ClientConfig('<string literal>')` yields a name. A variable, a template string
173
+ * or a computed expression yields null — the target is genuinely unknown at scan time, and the
174
+ * runtime graph must fall back to fan-out (loudly) rather than guess.
175
+ */
176
+ // webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts
177
+ function targetServiceOf(call) {
178
+ if (call.arguments.length < 2)
179
+ return null;
180
+ const config = call.arguments[1];
181
+ if (!ts.isNewExpression(config) || !ts.isIdentifier(config.expression))
182
+ return null;
183
+ if (!config.expression.text.endsWith(CLIENT_CONFIG_SUFFIX))
184
+ return null;
185
+ const first = config.arguments?.[0];
186
+ if (first === undefined || !ts.isStringLiteral(first))
187
+ return null;
188
+ return first.text.length > 0 ? first.text : null;
189
+ }
190
+ // webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts
191
+ function calleeMethodName(call) {
192
+ const callee = call.expression;
193
+ if (ts.isPropertyAccessExpression(callee))
194
+ return callee.name.text;
195
+ if (ts.isIdentifier(callee))
196
+ return callee.text;
197
+ return null;
198
+ }
199
+ // webpieces-disable no-function-outside-class -- pure path predicate, matching the sibling helpers in di-graph/bindings.ts
200
+ function isTestFile(fileName) {
201
+ return (fileName.includes('/__tests__/') ||
202
+ fileName.includes('.spec.') ||
203
+ fileName.includes('.test.'));
204
+ }
205
+ /** {api, owner, type:'rpc'|'pubsub'} for an in-repo contract class, else null. */
206
+ // webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts
207
+ function apiClassInfoFromNode(node, project) {
208
+ return ts.isClassDeclaration(node) ? apiClassInfoFrom(node, project) : null;
209
+ }
210
+ /**
211
+ * {api, owner, type:'external'} for a VENDOR contract, else null.
212
+ *
213
+ * A vendor contract cannot be detected the way an in-repo one is. It carries no @ApiPath (there is
214
+ * no route — the call leaves through a vendor SDK), and it is usually a plain `interface` bound to a
215
+ * Symbol token, which is not even a class. So inside a project the workspace has DECLARED external
216
+ * (`runtime-architecture.externalApiPaths`) the signal is structural instead: an exported
217
+ * `interface`/`abstract class` whose name ends in `Api`. That deliberately picks up `GmailApi` and
218
+ * `StorageApi` while leaving their DTOs, `*Config` types and `*Client` implementations alone.
219
+ */
220
+ // webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts
221
+ function externalApiInfoFrom(node, project) {
222
+ const named = ts.isInterfaceDeclaration(node) || (ts.isClassDeclaration(node) && isAbstractClass(node));
223
+ if (!named || !node.name || !isExported(node))
224
+ return null;
225
+ const api = node.name.text;
226
+ if (!api.endsWith(EXTERNAL_CONTRACT_SUFFIX))
227
+ return null;
228
+ return { api, owner: project, type: 'external', methods: [] };
229
+ }
230
+ /** True when the declaration carries an `export` modifier. */
231
+ // webpieces-disable no-function-outside-class -- pure AST predicate, matching the sibling helpers in di-graph/bindings.ts
232
+ function isExported(node) {
233
+ return (ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
234
+ }
235
+ // webpieces-disable no-function-outside-class -- recursive fs walker, matching the AST-helper style here
236
+ function collectTsFiles(dir) {
237
+ const out = [];
238
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
239
+ const full = path.join(dir, entry.name);
240
+ if (entry.isDirectory()) {
241
+ if (entry.name !== 'node_modules')
242
+ out.push(...collectTsFiles(full));
243
+ }
244
+ else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
245
+ out.push(full);
246
+ }
247
+ }
248
+ return out;
249
+ }
250
+ //# sourceMappingURL=api-ast.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-ast.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/nx-webpieces-rules/src/lib/api-usage/api-ast.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * API contract AST accessors\n *\n * The pure, stateless half of the api scan: given a TypeScript node, what contract / endpoint /\n * injected type does it describe? Split out of api-scanner.ts, which owns the STATEFUL walk (project\n * programs, the source index, relation accumulation) and had grown past the file-size limit.\n *\n * Everything here is parser-level on purpose. Decorators must be read exactly as written, and a\n * plain parse cannot be diverted to a decorator-erased `.d.ts` by module resolution — the bug\n * api-scanner's source pre-pass exists to guard against.\n */\n\nimport * as ts from 'typescript';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { classDecorators, decoratorName } from '../di-graph/bindings';\nimport { ApiClassInfo, ApiMethodMeta, ApiTransport, EndpointKind } from './api-relations';\n\n/** Legal `@Endpoint(path, kind)` values; anything else is a source error, not a kind we invent. */\nconst ENDPOINT_KINDS: readonly EndpointKind[] = ['rpc', 'cloudtasks', 'cron', 'external'];\n\n/**\n * Name suffix that marks an exported type in an `externalApiPaths` project as a vendor CONTRACT\n * (`GmailApi`, `StorageApi`) rather than one of the DTOs, configs or clients sitting beside it.\n * The same convention the in-repo contracts already follow, applied where no decorator can be read.\n */\nconst EXTERNAL_CONTRACT_SUFFIX = 'Api';\n\n/**\n * Client-config class-name suffix whose FIRST constructor argument is the target service name —\n * `ClientConfig('helper-fsdb')` (rpc) and `TaskClientConfig('helper-fsdb')` (pubsub) both take\n * `svcName` first, and a consumer's own `XxxClientConfig` follows the same shape.\n */\nconst CLIENT_CONFIG_SUFFIX = 'ClientConfig';\n\n\n/** {api, owner: `project`, type} when `cls` is an `abstract class` carrying `@ApiPath`, else null. */\n// webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts\nexport function apiClassInfoFrom(cls: ts.ClassDeclaration, project: string): ApiClassInfo | null {\n if (!isAbstractClass(cls) || !hasClassDecorator(cls, 'ApiPath') || !cls.name) return null;\n const api = cls.name.text;\n const info: ApiClassInfo = {\n api,\n owner: project,\n type: apiTransport(cls),\n methods: endpointMethodsOf(cls, api),\n };\n const basePath = decoratorStringArg(cls, 'ApiPath');\n if (basePath !== null) info.basePath = basePath;\n return info;\n}\n\n// webpieces-disable no-function-outside-class -- pure AST predicate, matching the sibling helpers in di-graph/bindings.ts\nexport function apiTransport(cls: ts.ClassDeclaration): ApiTransport {\n return hasClassDecorator(cls, 'PubSub') ? 'pubsub' : 'rpc';\n}\n\n/**\n * Every `@Endpoint(path, kind)` method on a contract class, in declaration order.\n *\n * `kind` is a REQUIRED argument of the decorator, so a missing/non-literal second argument means the\n * source does not compile (or is mid-edit) — we skip the method rather than defaulting it. Defaulting\n * would put an undeclared cron or webhook into the graph as an ordinary rpc call, which is precisely\n * the blindness the required argument exists to remove.\n */\n// webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts\nexport function endpointMethodsOf(cls: ts.ClassDeclaration, api: string): ApiMethodMeta[] {\n const methods: ApiMethodMeta[] = [];\n for (const member of cls.members) {\n if (!ts.isMethodDeclaration(member) || !ts.isIdentifier(member.name)) continue;\n const endpoint = memberDecorator(member, 'Endpoint');\n if (endpoint === null) continue;\n const args = decoratorArgs(endpoint);\n const path = args[0] !== undefined && ts.isStringLiteral(args[0]) ? args[0].text : null;\n const kind = args[1] !== undefined && ts.isStringLiteral(args[1]) ? args[1].text : null;\n if (path === null || kind === null || !ENDPOINT_KINDS.includes(kind as EndpointKind)) continue;\n const name = member.name.text;\n const override = memberDecorator(member, 'Queue');\n const queueArg = override === null ? undefined : decoratorArgs(override)[0];\n const queueName =\n queueArg !== undefined && ts.isStringLiteral(queueArg) ? queueArg.text : `${api}-${name}`;\n methods.push({ name, path, kind: kind as EndpointKind, queueName });\n }\n return methods;\n}\n\n/** The arguments of a decorator's call expression, or [] when it is a bare `@Foo` reference. */\n// webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts\nexport function decoratorArgs(decorator: ts.Decorator): ts.NodeArray<ts.Expression> | ts.Expression[] {\n return ts.isCallExpression(decorator.expression) ? decorator.expression.arguments : [];\n}\n\n/** The named decorator on a class member, or null. */\n// webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts\nexport function memberDecorator(member: ts.ClassElement, name: string): ts.Decorator | null {\n const decorators = ts.getDecorators(member as ts.HasDecorators) ?? [];\n return decorators.find((d: ts.Decorator) => decoratorName(d) === name) ?? null;\n}\n\n/** The first argument of a class decorator when it is a string literal (`@ApiPath('/x')`), else null. */\n// webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts\nexport function decoratorStringArg(cls: ts.ClassDeclaration, name: string): string | null {\n const decorator = classDecorators(cls).find((d: ts.Decorator) => decoratorName(d) === name);\n if (decorator === undefined) return null;\n const first = decoratorArgs(decorator)[0];\n return first !== undefined && ts.isStringLiteral(first) ? first.text : null;\n}\n\n/** The constructor's parameters, or [] when the class declares no constructor. */\n// webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts\nexport function constructorParamsOf(cls: ts.ClassDeclaration): readonly ts.ParameterDeclaration[] {\n for (const member of cls.members) {\n if (ts.isConstructorDeclaration(member)) return member.parameters;\n }\n return [];\n}\n\n/**\n * The bare name of a type reference (`GmailApi`, or `gmail.GmailApi` -> `GmailApi`), else null.\n * Generic wrappers are deliberately NOT unwrapped: `Provider<GmailApi>` hands out the contract\n * lazily, which is still a use, but it is not the shape any of these seams take today and guessing\n * at type arguments would start matching things that merely mention a contract.\n */\n// webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts\nexport function typeReferenceName(type: ts.TypeNode | undefined): string | null {\n if (type === undefined || !ts.isTypeReferenceNode(type)) return null;\n const name = type.typeName;\n if (ts.isIdentifier(name)) return name.text;\n return ts.isQualifiedName(name) && ts.isIdentifier(name.right) ? name.right.text : null;\n}\n\n/** Every type name in the class's `implements` clause — the contracts this class IS, not ones it calls. */\n// webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts\nexport function implementedTypeNames(cls: ts.ClassDeclaration): Set<string> {\n const names = new Set<string>();\n for (const clause of cls.heritageClauses ?? []) {\n if (clause.token !== ts.SyntaxKind.ImplementsKeyword) continue;\n for (const type of clause.types) {\n if (ts.isIdentifier(type.expression)) names.add(type.expression.text);\n }\n }\n return names;\n}\n\n// webpieces-disable no-function-outside-class -- pure AST predicate, matching the sibling helpers in di-graph/bindings.ts\nexport function isAbstractClass(cls: ts.ClassDeclaration): boolean {\n return (ts.getModifiers(cls) ?? []).some((m: ts.Modifier) => m.kind === ts.SyntaxKind.AbstractKeyword);\n}\n\n// webpieces-disable no-function-outside-class -- pure AST predicate, matching the sibling helpers in di-graph/bindings.ts\nexport function hasClassDecorator(cls: ts.ClassDeclaration, name: string): boolean {\n return classDecorators(cls).some((d: ts.Decorator) => decoratorName(d) === name);\n}\n\n/**\n * The service a client-factory call aims at, from its config argument:\n * `createRpcClient(WarmupApi, new ClientConfig('helper-fsdb'))` → `'helper-fsdb'`.\n *\n * Only a `new <Xxx>ClientConfig('<string literal>')` yields a name. A variable, a template string\n * or a computed expression yields null — the target is genuinely unknown at scan time, and the\n * runtime graph must fall back to fan-out (loudly) rather than guess.\n */\n// webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts\nexport function targetServiceOf(call: ts.CallExpression): string | null {\n if (call.arguments.length < 2) return null;\n const config = call.arguments[1];\n if (!ts.isNewExpression(config) || !ts.isIdentifier(config.expression)) return null;\n if (!config.expression.text.endsWith(CLIENT_CONFIG_SUFFIX)) return null;\n const first = config.arguments?.[0];\n if (first === undefined || !ts.isStringLiteral(first)) return null;\n return first.text.length > 0 ? first.text : null;\n}\n\n// webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts\nexport function calleeMethodName(call: ts.CallExpression): string | null {\n const callee = call.expression;\n if (ts.isPropertyAccessExpression(callee)) return callee.name.text;\n if (ts.isIdentifier(callee)) return callee.text;\n return null;\n}\n\n// webpieces-disable no-function-outside-class -- pure path predicate, matching the sibling helpers in di-graph/bindings.ts\nexport function isTestFile(fileName: string): boolean {\n return (\n fileName.includes('/__tests__/') ||\n fileName.includes('.spec.') ||\n fileName.includes('.test.')\n );\n}\n\n\n/** {api, owner, type:'rpc'|'pubsub'} for an in-repo contract class, else null. */\n// webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts\nexport function apiClassInfoFromNode(node: ts.Node, project: string): ApiClassInfo | null {\n return ts.isClassDeclaration(node) ? apiClassInfoFrom(node, project) : null;\n}\n\n/**\n * {api, owner, type:'external'} for a VENDOR contract, else null.\n *\n * A vendor contract cannot be detected the way an in-repo one is. It carries no @ApiPath (there is\n * no route — the call leaves through a vendor SDK), and it is usually a plain `interface` bound to a\n * Symbol token, which is not even a class. So inside a project the workspace has DECLARED external\n * (`runtime-architecture.externalApiPaths`) the signal is structural instead: an exported\n * `interface`/`abstract class` whose name ends in `Api`. That deliberately picks up `GmailApi` and\n * `StorageApi` while leaving their DTOs, `*Config` types and `*Client` implementations alone.\n */\n// webpieces-disable no-function-outside-class -- pure AST accessor, matching the sibling helpers in di-graph/bindings.ts\nexport function externalApiInfoFrom(node: ts.Node, project: string): ApiClassInfo | null {\n const named = ts.isInterfaceDeclaration(node) || (ts.isClassDeclaration(node) && isAbstractClass(node));\n if (!named || !node.name || !isExported(node)) return null;\n const api = node.name.text;\n if (!api.endsWith(EXTERNAL_CONTRACT_SUFFIX)) return null;\n return { api, owner: project, type: 'external', methods: [] };\n}\n\n/** True when the declaration carries an `export` modifier. */\n// webpieces-disable no-function-outside-class -- pure AST predicate, matching the sibling helpers in di-graph/bindings.ts\nexport function isExported(node: ts.InterfaceDeclaration | ts.ClassDeclaration): boolean {\n return (ts.getModifiers(node) ?? []).some((m: ts.Modifier) => m.kind === ts.SyntaxKind.ExportKeyword);\n}\n\n// webpieces-disable no-function-outside-class -- recursive fs walker, matching the AST-helper style here\nexport function collectTsFiles(dir: string): string[] {\n const out: string[] = [];\n for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n if (entry.name !== 'node_modules') out.push(...collectTsFiles(full));\n } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {\n out.push(full);\n }\n }\n return out;\n}\n"]}
@@ -13,8 +13,17 @@
13
13
  * `implements`/`uses` are legal interface property names (they are reserved words
14
14
  * only as binding identifiers, not as member names).
15
15
  */
16
- /** Transport of an API contract: synchronous RPC (HTTP) vs fire-and-forget PubSub (Cloud Tasks). */
17
- export type ApiTransport = 'rpc' | 'pubsub';
16
+ /**
17
+ * Transport of an API contract:
18
+ * - `rpc` — synchronous request/response over HTTP
19
+ * - `pubsub` — fire-and-forget, delivered later through a Cloud Tasks queue
20
+ * - `external` — a contract for a system OUTSIDE this repo (firestore, gmail, ...). Nothing in-repo
21
+ * implements it, so it never becomes a service→service edge; it terminates the graph
22
+ * at a dashed vendor node. Detected from `runtime-architecture.externalApiPaths`
23
+ * rather than from a decorator, because a vendor contract is a plain interface bound
24
+ * to a Symbol token, not an `abstract class` carrying @ApiPath.
25
+ */
26
+ export type ApiTransport = 'rpc' | 'pubsub' | 'external';
18
27
  /**
19
28
  * How a project relates to ONE api-lib it depends on:
20
29
  * - `implements` — it serves the api (a controller extends it)
@@ -39,6 +48,17 @@ export interface ApiRef {
39
48
  * falls back to the old fan-out and says so out loud.
40
49
  */
41
50
  targetService?: string;
51
+ /**
52
+ * ONLY on a `pubsub` uses ref. True means "this producer was attributed to EVERY cloudtasks
53
+ * method of the contract, not to the methods it actually enqueues".
54
+ *
55
+ * A producer builds one client for the whole contract (`createPubSubClient(EmailTaskApi, cfg)`)
56
+ * and enqueues through a proxy (`emailTasks.send(req)`) somewhere else entirely — often after
57
+ * the client has been stored in a DI binding — so WHICH methods it enqueues is not statically
58
+ * recoverable. The consumer side IS exact (addRoutes + the contract's method table). Recording
59
+ * the difference keeps a producer-side queue from being read as proof that queue is used.
60
+ */
61
+ methodsInferred?: boolean;
42
62
  }
43
63
  /**
44
64
  * Identity of a ref for de-duplication: an api used twice against DIFFERENT services is two distinct
@@ -56,12 +76,61 @@ export interface ApiRelation {
56
76
  }
57
77
  /** apiLibProjectName -> relation. Attached to a GraphEntry as `apiRelations`. */
58
78
  export type ProjectApiRelations = Record<string, ApiRelation>;
59
- /** A discovered API contract class: its name, the api-lib project that owns it, and its transport. */
79
+ /**
80
+ * What triggers ONE endpoint, mirroring core-util's `EndpointKind`. Duplicated as a string union
81
+ * rather than imported: nx-webpieces-rules is build tooling and must not take a runtime dependency
82
+ * on the framework it inspects (it reads decorators as TEXT, from projects that may be on a
83
+ * different @webpieces version than the tooling itself).
84
+ */
85
+ export type EndpointKind = 'rpc' | 'cloudtasks' | 'cron' | 'external';
86
+ /**
87
+ * One method on an API contract, as written in source: what triggers it, where it is mounted, and
88
+ * (for a queued method) which Cloud Tasks queue delivers it.
89
+ */
90
+ export interface ApiMethodMeta {
91
+ name: string;
92
+ /** The @Endpoint path, relative to the class's @ApiPath basePath. */
93
+ path: string;
94
+ kind: EndpointKind;
95
+ /**
96
+ * `@Queue(...)` override, else `${ApiClassName}-${methodName}`. Present for every method so a
97
+ * `cron` schedule and a `cloudtasks` queue are both nameable; Terraform matches on this string.
98
+ */
99
+ queueName: string;
100
+ }
101
+ /**
102
+ * A discovered API contract class: its name, the api-lib project that owns it, its transport, and
103
+ * its per-method trigger table.
104
+ */
60
105
  export interface ApiClassInfo {
61
106
  api: string;
62
107
  owner: string;
63
108
  type: ApiTransport;
109
+ /** The class's @ApiPath basePath; absent for an external (vendor) contract, which has no route. */
110
+ basePath?: string;
111
+ /**
112
+ * Every @Endpoint method, in declaration order. Empty for an external contract (a vendor
113
+ * interface has no endpoints — it is called through a vendor SDK, not mounted).
114
+ */
115
+ methods: ApiMethodMeta[];
116
+ }
117
+ /**
118
+ * The committed, per-contract view written to `architecture/dependencies.json` under `apiContracts`.
119
+ *
120
+ * The runtime graph is derived SOLELY from dependencies.json so generate and validate can never
121
+ * diverge — which means anything the runtime graph needs must be COMMITTED there, not re-scanned.
122
+ * Per-method trigger kinds and queue names are exactly that: without this table the derivation
123
+ * cannot tell a queued endpoint from a cron sweep, and cannot name the queue between two services.
124
+ */
125
+ export interface ApiContract {
126
+ owner: string;
127
+ /** 'rpc' | 'pubsub' for an in-repo contract, 'external' for a vendor seam. */
128
+ apiKind: ApiTransport;
129
+ basePath?: string;
130
+ methods: ApiMethodMeta[];
64
131
  }
132
+ /** apiClassName -> its committed contract. Serialized as the `apiContracts` key. */
133
+ export type ApiContracts = Record<string, ApiContract>;
65
134
  /** Derive the relation kind from the (possibly empty) implements/uses ref lists. */
66
135
  export declare function deriveApiRelationKind(implementsRefs: ApiRef[], usesRefs: ApiRef[]): ApiRelationKind;
67
136
  /**
@@ -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":";AAAA;;;;;;;;;;;;;;GAcG;;AAqCH,8BAEC;AAwBD,sDAIC;AAOD,kCAKC;AA/CD;;;GAGG;AACH,+FAA+F;AAC/F,SAAgB,SAAS,CAAC,GAAW;IACjC,OAAO,GAAG,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,aAAa,IAAI,EAAE,EAAE,CAAC;AACnD,CAAC;AAsBD,oFAAoF;AACpF,+FAA+F;AAC/F,SAAgB,qBAAqB,CAAC,cAAwB,EAAE,QAAkB;IAC9E,IAAI,cAAc,CAAC,MAAM,GAAG,CAAC,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,iBAAiB,CAAC;IAC/E,IAAI,cAAc,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,YAAY,CAAC;IACnD,OAAO,MAAM,CAAC;AAClB,CAAC;AAED;;;GAGG;AACH,+FAA+F;AAC/F,SAAgB,WAAW,CAAC,IAAc;IACtC,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC,IAAI,CACjB,CAAC,CAAS,EAAE,CAAS,EAAE,EAAE,CACrB,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,aAAa,IAAI,EAAE,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,aAAa,IAAI,EAAE,CAAC,CACjG,CAAC;AACN,CAAC","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/** Transport of an API contract: synchronous RPC (HTTP) vs fire-and-forget PubSub (Cloud Tasks). */\nexport type ApiTransport = 'rpc' | 'pubsub';\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\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/** A discovered API contract class: its name, the api-lib project that owns it, and its transport. */\nexport interface ApiClassInfo {\n api: string;\n owner: string;\n type: ApiTransport;\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(implementsRefs: ApiRef[], usesRefs: ApiRef[]): 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) || (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":";AAAA;;;;;;;;;;;;;;GAcG;;AAyDH,8BAEC;AA6ED,sDAIC;AAOD,kCAKC;AApGD;;;GAGG;AACH,+FAA+F;AAC/F,SAAgB,SAAS,CAAC,GAAW;IACjC,OAAO,GAAG,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,aAAa,IAAI,EAAE,EAAE,CAAC;AACnD,CAAC;AA2ED,oFAAoF;AACpF,+FAA+F;AAC/F,SAAgB,qBAAqB,CAAC,cAAwB,EAAE,QAAkB;IAC9E,IAAI,cAAc,CAAC,MAAM,GAAG,CAAC,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,iBAAiB,CAAC;IAC/E,IAAI,cAAc,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,YAAY,CAAC;IACnD,OAAO,MAAM,CAAC;AAClB,CAAC;AAED;;;GAGG;AACH,+FAA+F;AAC/F,SAAgB,WAAW,CAAC,IAAc;IACtC,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC,IAAI,CACjB,CAAC,CAAS,EAAE,CAAS,EAAE,EAAE,CACrB,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,aAAa,IAAI,EAAE,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,aAAa,IAAI,EAAE,CAAC,CACjG,CAAC;AACN,CAAC","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 * 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/**\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 /**\n * `@Queue(...)` override, else `${ApiClassName}-${methodName}`. Present for every method so a\n * `cron` schedule and a `cloudtasks` queue are both nameable; Terraform matches on this string.\n */\n queueName: string;\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\n/**\n * The committed, per-contract view written to `architecture/dependencies.json` under `apiContracts`.\n *\n * The runtime graph is derived SOLELY from dependencies.json so generate and validate can never\n * diverge — which means anything the runtime graph needs must be COMMITTED there, 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 basePath?: string;\n methods: ApiMethodMeta[];\n}\n\n/** apiClassName -> its committed contract. Serialized as the `apiContracts` key. */\nexport type ApiContracts = Record<string, ApiContract>;\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(implementsRefs: ApiRef[], usesRefs: ApiRef[]): 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) || (a.targetService ?? '').localeCompare(b.targetService ?? ''),\n );\n}\n"]}
@@ -29,7 +29,7 @@
29
29
  */
30
30
  import type { EnhancedGraph } from '../graph-sorter';
31
31
  import { ProjectInfo } from '../project-info';
32
- import { ApiClassInfo, ProjectApiRelations } from './api-relations';
32
+ import { ApiClassInfo, ApiContracts, ProjectApiRelations } from './api-relations';
33
33
  /**
34
34
  * An `addRoutes`/`createRpcClient`/`createPubSubClient` first argument that resolved to an
35
35
  * abstract class in a DECLARATION file which owns no indexed contract. Unambiguously a broken
@@ -79,15 +79,34 @@ export interface ApiScanResult {
79
79
  export declare class ApiUsageScanner {
80
80
  private readonly workspaceRoot;
81
81
  private readonly projectInfos;
82
+ /** Globs of project roots whose exported `*Api` types are contracts for outside systems. */
83
+ private readonly externalApiPaths;
82
84
  private readonly locator;
83
85
  private readonly relationsByProject;
84
86
  private readonly scannedProjects;
85
87
  private readonly unresolvedApiCalls;
86
88
  private sourceIndex;
87
- constructor(workspaceRoot: string, projectInfos: Map<string, ProjectInfo>);
89
+ constructor(workspaceRoot: string, projectInfos: Map<string, ProjectInfo>,
90
+ /** Globs of project roots whose exported `*Api` types are contracts for outside systems. */
91
+ externalApiPaths?: readonly string[]);
88
92
  scan(): ApiScanResult;
89
93
  private scanProject;
90
94
  private visit;
95
+ /**
96
+ * Record a `uses` for every vendor contract this class receives by CONSTRUCTOR INJECTION —
97
+ * `constructor(@inject(GMAIL_TYPES.GmailApi) private readonly gmail: GmailApi)`.
98
+ *
99
+ * The parameter TYPE is the signal, not the token: a token is an opaque Symbol whose name we
100
+ * would have to guess at, while the type is written right there and is what the class actually
101
+ * calls. Matching happens by name against the external index, so an import that resolves to a
102
+ * built `.d.ts` works exactly as well as one resolving to source.
103
+ *
104
+ * A class that IMPLEMENTS the contract is skipped — that is the vendor adapter (`GmailClient`)
105
+ * or a test double (`InMemoryFirestore`, `MockTts`), which IS the seam rather than a caller of
106
+ * it. Counting those would draw an edge from every service embedding a fake to a vendor it never
107
+ * actually reaches.
108
+ */
109
+ private recordExternalUses;
91
110
  private recordCall;
92
111
  /** Resolve an expression to the API contract it names, or null if it is not one. */
93
112
  private apiInfoFromExpr;
@@ -102,7 +121,12 @@ export declare class ApiUsageScanner {
102
121
  /** `path/to/file.ts:LINE` for `node`, workspace-relative, for a human-readable report. */
103
122
  private relativeLocation;
104
123
  private relativePath;
105
- /** {api, owner, type} when `cls` is an `abstract class` carrying `@ApiPath` IN SOURCE, else null. */
124
+ /**
125
+ * {api, owner, type, methods} when `cls` is an `abstract class` carrying `@ApiPath` IN SOURCE,
126
+ * else null. Only the OWNER differs from the index pre-pass — here it comes from the file's
127
+ * location rather than from the project being walked — so the contract test itself is delegated
128
+ * to apiClassInfoFrom, keeping one definition of "this is a contract".
129
+ */
106
130
  private apiClassInfoFor;
107
131
  }
108
132
  /**
@@ -112,7 +136,22 @@ export declare class ApiUsageScanner {
112
136
  * and must attach the SAME field, or it would see a phantom diff). Returns the
113
137
  * full scan so callers (validators, runtime graph) can reuse the api index.
114
138
  */
115
- export declare function scanAndAttachApiRelations(workspaceRoot: string, graph: EnhancedGraph, projectInfos: Map<string, ProjectInfo>): ApiScanResult;
139
+ export declare function scanAndAttachApiRelations(workspaceRoot: string, graph: EnhancedGraph, projectInfos: Map<string, ProjectInfo>, externalApiPaths?: readonly string[]): ApiScanResult;
140
+ /**
141
+ * The committed `apiContracts` table for architecture/dependencies.json, from a completed scan.
142
+ *
143
+ * Only contracts with ≥1 endpoint are emitted: a vendor seam has no routes, so a table entry for it
144
+ * would be an empty shell, and its identity is already carried by the `external` refs in
145
+ * apiRelations. Sorted by api name, methods left in declaration order, so the file is deterministic.
146
+ */
147
+ export declare function buildApiContracts(scan: ApiScanResult): ApiContracts;
148
+ /**
149
+ * Every contract method whose declared @Endpoint kind its api kind cannot deliver — an rpc method on
150
+ * a @PubSub contract (nothing calls a queue synchronously), or a cloudtasks/cron method on an @Rpc
151
+ * contract (naming a queue or schedule nothing could deliver to). Mirrors core-util's
152
+ * ENDPOINT_KINDS_BY_API_KIND at BUILD time, where it can name the file instead of throwing at wiring.
153
+ */
154
+ export declare function describeMismatchedEndpointKinds(contracts: ApiContracts): string[];
116
155
  /**
117
156
  * Loud, actionable report for contracts the scan could not map to source. Callers print this
118
157
  * instead of emitting a green graph that is quietly missing relations. Not fatal: a contract