@bleedingdev/modern-js-code-tools 3.9.0-ultramodern.1 → 3.9.0-ultramodern.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/modern-api-check-files.mjs +4 -0
- package/bin/modern-api-check.mjs +4 -0
- package/dist/cjs/cli/microvertical-api-check.cjs +71 -0
- package/dist/cjs/cli/oxlint.cjs +9 -5
- package/dist/cjs/microvertical-api-baseline.cjs +677 -0
- package/dist/cjs/microvertical-api-boundary.cjs +507 -0
- package/dist/cjs/microvertical-api-owner.cjs +217 -0
- package/dist/cjs/oxlint-plugin/rules/strict-effect-api-boundaries.cjs +7 -5
- package/dist/cjs/strict-effect-runtime.cjs +411 -0
- package/dist/esm/cli/microvertical-api-check.js +33 -0
- package/dist/esm/cli/oxlint.js +10 -6
- package/dist/esm/microvertical-api-baseline.js +621 -0
- package/dist/esm/microvertical-api-boundary.js +449 -0
- package/dist/esm/microvertical-api-owner.js +162 -0
- package/dist/esm/oxlint-plugin/rules/strict-effect-api-boundaries.js +7 -5
- package/dist/esm/strict-effect-runtime.js +358 -0
- package/dist/esm-node/cli/microvertical-api-check.js +34 -0
- package/dist/esm-node/cli/oxlint.js +10 -6
- package/dist/esm-node/microvertical-api-baseline.js +622 -0
- package/dist/esm-node/microvertical-api-boundary.js +450 -0
- package/dist/esm-node/microvertical-api-owner.js +163 -0
- package/dist/esm-node/oxlint-plugin/rules/strict-effect-api-boundaries.js +7 -5
- package/dist/esm-node/strict-effect-runtime.js +359 -0
- package/dist/types/cli/microvertical-api-check.d.ts +1 -0
- package/dist/types/microvertical-api-baseline.d.ts +28 -0
- package/dist/types/microvertical-api-boundary.d.ts +32 -0
- package/dist/types/microvertical-api-owner.d.ts +6 -0
- package/dist/types/strict-effect-runtime.d.ts +10 -0
- package/package.json +25 -4
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import "node:module";
|
|
2
|
+
import node_fs from "node:fs";
|
|
3
|
+
import node_path from "node:path";
|
|
4
|
+
import { parse } from "@babel/parser";
|
|
5
|
+
import { configuredMicroVerticalApiStem, consumerParserPlugins, microVerticalApiBaselineViolation } from "./microvertical-api-baseline.js";
|
|
6
|
+
import { baselinePackage, resolveBaselinePackageDirectory } from "./microvertical-api-owner.js";
|
|
7
|
+
import { createEffectApiImportResolver, strictEffectRuntimeTopologyViolation } from "./strict-effect-runtime.js";
|
|
8
|
+
import * as __rspack_external__babel_types_6e8337dd from "@babel/types";
|
|
9
|
+
const ignored = new Set([
|
|
10
|
+
'.git',
|
|
11
|
+
'.modern',
|
|
12
|
+
'.modernjs',
|
|
13
|
+
'.output',
|
|
14
|
+
'coverage',
|
|
15
|
+
'dist',
|
|
16
|
+
'node_modules',
|
|
17
|
+
'repos'
|
|
18
|
+
]);
|
|
19
|
+
const normalize = (file)=>file.split(node_path.sep).join('/').replace(/^\.\//u, '');
|
|
20
|
+
const errorMessage = (error)=>error instanceof Error ? error.message : String(error);
|
|
21
|
+
const apiApp = (app)=>'vertical' === app.kind && 'ui-only' !== app.surfaceProfile && 'horizontal-remote' !== app.deliveryUnitKind;
|
|
22
|
+
function configuredApps(root, supplied) {
|
|
23
|
+
const source = supplied ?? JSON.parse(node_fs.readFileSync(node_path.join(root, '.modernjs/ultramodern.json'), 'utf8')).topology?.apps;
|
|
24
|
+
if (!Array.isArray(source)) throw new Error('.modernjs/ultramodern.json: topology.apps must be an array');
|
|
25
|
+
const paths = new Set();
|
|
26
|
+
for (const app of source){
|
|
27
|
+
if (!app || 'object' != typeof app || 'string' != typeof app.path || !app.path || node_path.isAbsolute(app.path) || app.path.split(/[\\/]/u).includes('..')) throw new Error('.modernjs/ultramodern.json: each app must have a relative workspace path');
|
|
28
|
+
const key = normalize(app.path);
|
|
29
|
+
if (paths.has(key)) throw new Error(`.modernjs/ultramodern.json: duplicate app path ${key}`);
|
|
30
|
+
paths.add(key);
|
|
31
|
+
for (const name of [
|
|
32
|
+
'id',
|
|
33
|
+
'kind',
|
|
34
|
+
'package',
|
|
35
|
+
'surfaceProfile',
|
|
36
|
+
'deliveryUnitKind'
|
|
37
|
+
])if (void 0 !== app[name] && 'string' != typeof app[name]) throw new Error(`${key}: ${name} must be a string`);
|
|
38
|
+
for (const [field, allowed] of Object.entries({
|
|
39
|
+
kind: [
|
|
40
|
+
'shell',
|
|
41
|
+
'vertical'
|
|
42
|
+
],
|
|
43
|
+
surfaceProfile: [
|
|
44
|
+
'full-stack',
|
|
45
|
+
'api-only',
|
|
46
|
+
'ui-only'
|
|
47
|
+
],
|
|
48
|
+
deliveryUnitKind: [
|
|
49
|
+
'microvertical',
|
|
50
|
+
'horizontal-remote'
|
|
51
|
+
]
|
|
52
|
+
}))if (void 0 !== app[field] && !allowed.includes(app[field])) throw new Error(`${key}: invalid ${field}`);
|
|
53
|
+
if (void 0 !== app.api) {
|
|
54
|
+
if (!app.api || 'object' != typeof app.api || Array.isArray(app.api)) throw new Error(`${key}: api must be an object`);
|
|
55
|
+
if (void 0 !== app.api.protocol && ![
|
|
56
|
+
'rest',
|
|
57
|
+
'rpc'
|
|
58
|
+
].includes(app.api.protocol)) throw new Error(`${key}: api.protocol must be rest or rpc`);
|
|
59
|
+
if (void 0 !== app.api.stem && ('string' != typeof app.api.stem || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(app.api.stem))) throw new Error(`${key}: invalid api.stem`);
|
|
60
|
+
if (void 0 !== app.api.prefix && ('string' != typeof app.api.prefix || !/^\/[^\s?#]*$/u.test(app.api.prefix))) throw new Error(`${key}: invalid api.prefix`);
|
|
61
|
+
for (const field of [
|
|
62
|
+
'additionalPaths',
|
|
63
|
+
'operationPaths'
|
|
64
|
+
]){
|
|
65
|
+
const value = app.api[field];
|
|
66
|
+
if (void 0 !== value && (!value || 'object' != typeof value || Array.isArray(value) || Object.values(value).some((entry)=>'string' != typeof entry))) throw new Error(`${key}: api.${field} must map names to paths`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return source;
|
|
71
|
+
}
|
|
72
|
+
function check(options, topology) {
|
|
73
|
+
const diagnostics = [];
|
|
74
|
+
const toolErrors = [];
|
|
75
|
+
let topologyFilesAnalyzed = 0;
|
|
76
|
+
const root = node_path.resolve(options.workspaceRoot);
|
|
77
|
+
const absolute = (file)=>node_path.join(root, file);
|
|
78
|
+
const exists = (file)=>node_fs.existsSync(absolute(file));
|
|
79
|
+
const read = (file)=>node_fs.readFileSync(absolute(file), 'utf8');
|
|
80
|
+
const assert = (value, message)=>{
|
|
81
|
+
if (!value) diagnostics.push(message);
|
|
82
|
+
};
|
|
83
|
+
const noPath = (file, reason)=>assert(!exists(file), `${file}: ${reason}`);
|
|
84
|
+
const guarded = (file, work)=>{
|
|
85
|
+
try {
|
|
86
|
+
work();
|
|
87
|
+
} catch (error) {
|
|
88
|
+
toolErrors.push(`${file}: ${errorMessage(error)}`);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
const apps = configuredApps(root, options.configuredApps);
|
|
93
|
+
const byPath = new Map(apps.map((app)=>[
|
|
94
|
+
normalize(app.path),
|
|
95
|
+
app
|
|
96
|
+
]));
|
|
97
|
+
const directories = (base)=>exists(base) ? node_fs.readdirSync(absolute(base), {
|
|
98
|
+
withFileTypes: true
|
|
99
|
+
}).filter((entry)=>entry.isDirectory() && !ignored.has(entry.name)).map((entry)=>`${base}/${entry.name}`) : [];
|
|
100
|
+
const walk = (directory)=>exists(directory) ? node_fs.readdirSync(absolute(directory), {
|
|
101
|
+
withFileTypes: true
|
|
102
|
+
}).flatMap((entry)=>{
|
|
103
|
+
if (ignored.has(entry.name) || 'dist-cloudflare' === entry.name && byPath.has(directory)) return [];
|
|
104
|
+
const file = `${directory}/${entry.name}`;
|
|
105
|
+
return entry.isDirectory() ? walk(file) : entry.isFile() ? [
|
|
106
|
+
file
|
|
107
|
+
] : [];
|
|
108
|
+
}) : [];
|
|
109
|
+
const parsed = new Map();
|
|
110
|
+
const parseFile = (file)=>{
|
|
111
|
+
if (parsed.has(file)) return parsed.get(file);
|
|
112
|
+
if (node_fs.statSync(absolute(file)).size > 1000000) throw new Error('consumer source exceeds 1 MB analysis budget');
|
|
113
|
+
try {
|
|
114
|
+
const ast = parse(read(file), {
|
|
115
|
+
sourceType: 'module',
|
|
116
|
+
sourceFilename: file,
|
|
117
|
+
plugins: consumerParserPlugins(file)
|
|
118
|
+
});
|
|
119
|
+
parsed.set(file, ast);
|
|
120
|
+
return ast;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if ('object' == typeof error && null !== error && 'code' in error && 'BABEL_PARSER_SYNTAX_ERROR' === error.code) {
|
|
123
|
+
diagnostics.push(`${file}: invalid source syntax (${errorMessage(error)})`);
|
|
124
|
+
parsed.set(file, void 0);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const resolveRelative = (fromFile, specifier)=>{
|
|
131
|
+
if (!/^\.\.?\//u.test(specifier)) return;
|
|
132
|
+
const base = node_path.posix.join(node_path.posix.dirname(fromFile), specifier).replace(/\.(?:[cm]?[jt]sx?)$/u, '');
|
|
133
|
+
return [
|
|
134
|
+
`${base}.ts`,
|
|
135
|
+
`${base}.tsx`,
|
|
136
|
+
`${base}/index.ts`,
|
|
137
|
+
`${base}/index.tsx`
|
|
138
|
+
].find((candidate)=>exists(candidate));
|
|
139
|
+
};
|
|
140
|
+
const composedModules = (file)=>{
|
|
141
|
+
const order = [];
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
const queue = [
|
|
144
|
+
file
|
|
145
|
+
];
|
|
146
|
+
while(queue.length > 0 && order.length < 128){
|
|
147
|
+
const current = queue.shift();
|
|
148
|
+
if (seen.has(current)) continue;
|
|
149
|
+
seen.add(current);
|
|
150
|
+
order.push(current);
|
|
151
|
+
const ast = parseFile(current);
|
|
152
|
+
if (!ast) continue;
|
|
153
|
+
for (const statement of ast.program.body){
|
|
154
|
+
const source = (__rspack_external__babel_types_6e8337dd.isImportDeclaration(statement) || __rspack_external__babel_types_6e8337dd.isExportNamedDeclaration(statement) || __rspack_external__babel_types_6e8337dd.isExportAllDeclaration(statement)) && statement.source ? statement.source.value : void 0;
|
|
155
|
+
const resolved = void 0 === source ? void 0 : resolveRelative(current, source);
|
|
156
|
+
if (void 0 !== resolved && !seen.has(resolved)) queue.push(resolved);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return order;
|
|
160
|
+
};
|
|
161
|
+
const moduleShape = (file, imports, calls, patterns = [], defaultExport, composed = false)=>{
|
|
162
|
+
if (!exists(file)) return;
|
|
163
|
+
const ast = parseFile(file);
|
|
164
|
+
if (!ast) return;
|
|
165
|
+
const shapeFiles = composed ? composedModules(file) : [
|
|
166
|
+
file
|
|
167
|
+
];
|
|
168
|
+
const body = shapeFiles.flatMap((shapeFile)=>parseFile(shapeFile)?.program.body ?? []);
|
|
169
|
+
for (const [specifier, names] of imports){
|
|
170
|
+
const target = resolveRelative(file, specifier);
|
|
171
|
+
const matchesSpecifier = (item, from)=>void 0 === target ? item.source.value === specifier : resolveRelative(from, item.source.value) === target;
|
|
172
|
+
const imported = shapeFiles.flatMap((shapeFile)=>(parseFile(shapeFile)?.program.body ?? []).filter((item)=>__rspack_external__babel_types_6e8337dd.isImportDeclaration(item) && 'type' !== item.importKind && matchesSpecifier(item, shapeFile)));
|
|
173
|
+
assert(imported.length, `${file}: must import from ${specifier}`);
|
|
174
|
+
for (const name of names)assert(imported.some((item)=>__rspack_external__babel_types_6e8337dd.isImportDeclaration(item) && item.specifiers.some((value)=>__rspack_external__babel_types_6e8337dd.isImportSpecifier(value) && 'type' !== value.importKind && __rspack_external__babel_types_6e8337dd.isIdentifier(value.imported, {
|
|
175
|
+
name
|
|
176
|
+
}) && __rspack_external__babel_types_6e8337dd.isIdentifier(value.local, {
|
|
177
|
+
name
|
|
178
|
+
}))), `${file}: must import ${name} from ${specifier}`);
|
|
179
|
+
}
|
|
180
|
+
const found = new Set();
|
|
181
|
+
const collectCalls = (node)=>{
|
|
182
|
+
if (!__rspack_external__babel_types_6e8337dd.isCallExpression(node)) return;
|
|
183
|
+
if (__rspack_external__babel_types_6e8337dd.isIdentifier(node.callee)) found.add(node.callee.name);
|
|
184
|
+
if (__rspack_external__babel_types_6e8337dd.isMemberExpression(node.callee) && !node.callee.computed && __rspack_external__babel_types_6e8337dd.isIdentifier(node.callee.object) && __rspack_external__babel_types_6e8337dd.isIdentifier(node.callee.property)) found.add(`${node.callee.object.name}.${node.callee.property.name}`);
|
|
185
|
+
};
|
|
186
|
+
for (const shapeFile of shapeFiles){
|
|
187
|
+
const shapeAst = parseFile(shapeFile);
|
|
188
|
+
if (shapeAst) __rspack_external__babel_types_6e8337dd.traverseFast(shapeAst, collectCalls);
|
|
189
|
+
}
|
|
190
|
+
for (const call of calls)assert(found.has(call), `${file}: must call ${call}(...)`);
|
|
191
|
+
const source = read(file).replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/gu, '');
|
|
192
|
+
for (const [pattern, reason] of patterns)assert(pattern.test(source), `${file}: ${reason}`);
|
|
193
|
+
if (defaultExport) assert(body.some((item)=>__rspack_external__babel_types_6e8337dd.isExportDefaultDeclaration(item) && __rspack_external__babel_types_6e8337dd.isIdentifier(item.declaration, {
|
|
194
|
+
name: defaultExport
|
|
195
|
+
})), `${file}: must default-export ${defaultExport}`);
|
|
196
|
+
};
|
|
197
|
+
const effect = '@modern-js/bff-effect/effect-client';
|
|
198
|
+
const appDirectories = [
|
|
199
|
+
...new Set([
|
|
200
|
+
...directories('apps'),
|
|
201
|
+
...directories('verticals'),
|
|
202
|
+
...byPath.keys()
|
|
203
|
+
])
|
|
204
|
+
];
|
|
205
|
+
for (const appPath of appDirectories)for (const suffix of [
|
|
206
|
+
'api/effect',
|
|
207
|
+
'api/lambda',
|
|
208
|
+
'shared/effect',
|
|
209
|
+
'src/effect'
|
|
210
|
+
])noPath(`${appPath}/${suffix}`, 'legacy nested Effect paths are forbidden; use api/index.ts, shared/api.ts and src/api/*');
|
|
211
|
+
const sourceFiles = [
|
|
212
|
+
...new Set([
|
|
213
|
+
'apps',
|
|
214
|
+
'verticals',
|
|
215
|
+
'packages',
|
|
216
|
+
...byPath.keys()
|
|
217
|
+
].flatMap(walk))
|
|
218
|
+
].filter((file)=>/\.(?:[cm]?[jt]sx?|json|md)$/u.test(file));
|
|
219
|
+
for (const file of sourceFiles)guarded(file, ()=>{
|
|
220
|
+
const source = read(file);
|
|
221
|
+
const patterns = [
|
|
222
|
+
[
|
|
223
|
+
/@modern-js\/plugin-bff\/(?:server|hono-server)(?![\w-])/u,
|
|
224
|
+
'use Effect HttpApi instead of Hono helpers'
|
|
225
|
+
],
|
|
226
|
+
[
|
|
227
|
+
/\bruntimeFramework\s*(?::|=)\s*['"]hono['"]/u,
|
|
228
|
+
'must use the Effect runtime'
|
|
229
|
+
],
|
|
230
|
+
[
|
|
231
|
+
/\bstrictEffectApproach\s*(?::|=)\s*false\b/u,
|
|
232
|
+
'must keep strictEffectApproach enabled'
|
|
233
|
+
]
|
|
234
|
+
];
|
|
235
|
+
if (/\/api\//u.test(file)) patterns.push([
|
|
236
|
+
/\bnew\s+Response\s*\(|\bResponse\.json\s*\(/u,
|
|
237
|
+
'must not hand-build Response objects'
|
|
238
|
+
], [
|
|
239
|
+
/\b(?:request|req)\.(?:json|text|formData|arrayBuffer)\s*\(/u,
|
|
240
|
+
'must use endpoint payload/query/params schemas instead of parsing request bodies'
|
|
241
|
+
], [
|
|
242
|
+
/\bexport\s+const\s+handler\b|\bexport\s+default\s+async\b/u,
|
|
243
|
+
'must not export raw request handlers'
|
|
244
|
+
], [
|
|
245
|
+
/\bcreateHandler\s*[:=]\s*(?!defineEffectBff\b)/u,
|
|
246
|
+
'must use defineEffectBff instead of unbranded handler factories'
|
|
247
|
+
], [
|
|
248
|
+
/\bSchema\.(?:UnknownFromJsonString|Unknown|Any)\b/u,
|
|
249
|
+
'must use concrete request, response and error schemas'
|
|
250
|
+
]);
|
|
251
|
+
for (const [pattern, reason] of patterns)assert(!pattern.test(source), `${file}: ${reason}`);
|
|
252
|
+
if (topology && /\/api\/index\.ts$/u.test(file)) {
|
|
253
|
+
topologyFilesAnalyzed += 1;
|
|
254
|
+
const violation = strictEffectRuntimeTopologyViolation(source, createEffectApiImportResolver(absolute(file)));
|
|
255
|
+
if (violation) diagnostics.push(`${file}: ${violation}`);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
let owner;
|
|
259
|
+
for (const appPath of appDirectories)guarded(appPath, ()=>{
|
|
260
|
+
const app = byPath.get(appPath);
|
|
261
|
+
const emits = app ? apiApp(app) || 'vertical' !== app.kind && void 0 !== app.api : appPath.startsWith('verticals/') || exists(`${appPath}/api/index.ts`) || exists(`${appPath}/shared/api.ts`);
|
|
262
|
+
if (!emits) {
|
|
263
|
+
if (app?.kind === 'vertical') {
|
|
264
|
+
assert(void 0 === app.api, `${appPath}: UI/horizontal unit must not declare an API`);
|
|
265
|
+
for (const suffix of [
|
|
266
|
+
'api/index.ts',
|
|
267
|
+
'api/effect-api.ts',
|
|
268
|
+
'shared/api.ts',
|
|
269
|
+
'shared/rpc.ts',
|
|
270
|
+
'src/api'
|
|
271
|
+
])noPath(`${appPath}/${suffix}`, 'unit has no API surface');
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (app?.kind === 'vertical') assert(void 0 !== app.api, `${appPath}: vertical must declare its Effect API in .modernjs/ultramodern.json`);
|
|
276
|
+
const stem = app?.api?.stem ?? node_path.posix.basename(appPath);
|
|
277
|
+
const rpc = app?.api?.protocol === 'rpc';
|
|
278
|
+
const contract = `${appPath}/shared/${rpc ? 'rpc' : 'api'}.ts`;
|
|
279
|
+
const client = `${appPath}/src/api/${stem}-${rpc ? 'rpc-client' : 'client'}.ts`;
|
|
280
|
+
const entry = `${appPath}/api/index.ts`;
|
|
281
|
+
for (const file of [
|
|
282
|
+
entry,
|
|
283
|
+
contract,
|
|
284
|
+
client
|
|
285
|
+
])assert(exists(file), `${file}: required API surface is missing`);
|
|
286
|
+
noPath(`${appPath}/shared/${rpc ? 'api' : 'rpc'}.ts`, `must not emit a ${rpc ? 'REST' : 'RPC'} contract`);
|
|
287
|
+
noPath(`${appPath}/src/api/${stem}-${rpc ? 'client' : 'rpc-client'}.ts`, `must not emit a ${rpc ? 'REST' : 'RPC'} client`);
|
|
288
|
+
if (rpc) {
|
|
289
|
+
moduleShape(entry, [
|
|
290
|
+
[
|
|
291
|
+
'@modern-js/bff-effect/effect-edge',
|
|
292
|
+
[
|
|
293
|
+
'defineEffectBff',
|
|
294
|
+
'Effect',
|
|
295
|
+
'HttpApi',
|
|
296
|
+
'Layer'
|
|
297
|
+
]
|
|
298
|
+
],
|
|
299
|
+
[
|
|
300
|
+
'../shared/rpc.ts',
|
|
301
|
+
[]
|
|
302
|
+
]
|
|
303
|
+
], [
|
|
304
|
+
'defineEffectBff',
|
|
305
|
+
'HttpApi.make'
|
|
306
|
+
], [
|
|
307
|
+
[
|
|
308
|
+
/\bconst\s+\w+RpcLayer\s*=\s*\w+RpcGroup\s*\.\s*toLayer\s*\(/u,
|
|
309
|
+
'must implement RpcGroup.toLayer(...)'
|
|
310
|
+
],
|
|
311
|
+
[
|
|
312
|
+
/\brpc\s*:\s*\{[\s\S]*?\bgroup\s*:[\s\S]*?\blayer\s*:[\s\S]*?\bpath\s*:[\s\S]*?\bserialization\s*:/u,
|
|
313
|
+
'must register RPC group, layer, path and serialization'
|
|
314
|
+
]
|
|
315
|
+
], 'apiRuntime');
|
|
316
|
+
moduleShape(contract, [
|
|
317
|
+
[
|
|
318
|
+
'effect/unstable/rpc',
|
|
319
|
+
[
|
|
320
|
+
'Rpc',
|
|
321
|
+
'RpcGroup'
|
|
322
|
+
]
|
|
323
|
+
],
|
|
324
|
+
[
|
|
325
|
+
effect,
|
|
326
|
+
[
|
|
327
|
+
'Schema'
|
|
328
|
+
]
|
|
329
|
+
]
|
|
330
|
+
], [
|
|
331
|
+
'RpcGroup.make',
|
|
332
|
+
'Rpc.make',
|
|
333
|
+
'Schema.Struct'
|
|
334
|
+
]);
|
|
335
|
+
} else if (exists(contract)) {
|
|
336
|
+
owner ??= options.baselinePackageDirectory ?? resolveBaselinePackageDirectory(node_path.join(root, 'package.json'));
|
|
337
|
+
const apiPrefix = app?.api?.prefix ?? `/${stem}-api`;
|
|
338
|
+
const basePath = `${apiPrefix}/${stem}`;
|
|
339
|
+
const expectation = {
|
|
340
|
+
operationPaths: app?.api?.operationPaths,
|
|
341
|
+
additionalPaths: app?.api?.additionalPaths ?? {},
|
|
342
|
+
apiPrefix,
|
|
343
|
+
basePath,
|
|
344
|
+
effectClientPackage: effect,
|
|
345
|
+
ownerId: app?.id ?? node_path.posix.basename(appPath),
|
|
346
|
+
readinessPath: `${basePath}/readiness`,
|
|
347
|
+
baselinePackage: baselinePackage,
|
|
348
|
+
baselinePackageDirectory: owner
|
|
349
|
+
};
|
|
350
|
+
const violation = microVerticalApiBaselineViolation(stem, absolute(contract), expectation);
|
|
351
|
+
if (violation) diagnostics.push(`${contract}: ${violation}`);
|
|
352
|
+
}
|
|
353
|
+
moduleShape(client, [
|
|
354
|
+
[
|
|
355
|
+
effect,
|
|
356
|
+
[
|
|
357
|
+
'Effect',
|
|
358
|
+
rpc ? 'makeEffectRpcClient' : 'makeEffectHttpApiClient'
|
|
359
|
+
]
|
|
360
|
+
],
|
|
361
|
+
[
|
|
362
|
+
rpc ? '../../shared/rpc.ts' : '../../shared/api',
|
|
363
|
+
[]
|
|
364
|
+
]
|
|
365
|
+
], [
|
|
366
|
+
rpc ? 'makeEffectRpcClient' : 'makeEffectHttpApiClient'
|
|
367
|
+
], [], void 0, true);
|
|
368
|
+
moduleShape(`${appPath}/api/effect-api.ts`, [], [], [
|
|
369
|
+
[
|
|
370
|
+
/\bbackendFederationContract\b/u,
|
|
371
|
+
'must export backendFederationContract metadata'
|
|
372
|
+
],
|
|
373
|
+
[
|
|
374
|
+
/role:\s*['"]microvertical-server['"]/u,
|
|
375
|
+
'must describe the MicroVertical server role'
|
|
376
|
+
],
|
|
377
|
+
[
|
|
378
|
+
/strictEffectApproach:\s*true/u,
|
|
379
|
+
'must preserve strict Effect backend execution'
|
|
380
|
+
],
|
|
381
|
+
[
|
|
382
|
+
/contractVersion:\s*['"]microvertical-server-effect-v1['"]/u,
|
|
383
|
+
'must preserve the server contract version'
|
|
384
|
+
],
|
|
385
|
+
[
|
|
386
|
+
/export\s*\{\s*default\s*,\s*default\s+as\s+runtime\s*\}\s+from\s+['"]\.\/index\.ts['"]/u,
|
|
387
|
+
'must re-export the runtime as default and runtime'
|
|
388
|
+
],
|
|
389
|
+
[
|
|
390
|
+
/^(?![\s\S]*\b(request|handler)\s*:\s*async\s*\()/u,
|
|
391
|
+
'must not expose raw request handlers'
|
|
392
|
+
]
|
|
393
|
+
]);
|
|
394
|
+
moduleShape(`${appPath}/modern.config.ts`, [], [], [
|
|
395
|
+
[
|
|
396
|
+
/runtimeFramework:\s*['"]effect['"]/u,
|
|
397
|
+
'must use bff.runtimeFramework: effect'
|
|
398
|
+
],
|
|
399
|
+
[
|
|
400
|
+
/entry:\s*['"]\.\/api\/index['"]/u,
|
|
401
|
+
'must use ./api/index'
|
|
402
|
+
],
|
|
403
|
+
[
|
|
404
|
+
/strictEffectApproach:\s*true/u,
|
|
405
|
+
'must enable strictEffectApproach'
|
|
406
|
+
]
|
|
407
|
+
]);
|
|
408
|
+
const packageFile = `${appPath}/package.json`;
|
|
409
|
+
if (exists(packageFile)) {
|
|
410
|
+
const manifest = JSON.parse(read(packageFile));
|
|
411
|
+
assert(manifest.exports?.['./api'] === `./shared/${rpc ? 'rpc' : 'api'}.ts`, `${packageFile}: invalid ./api export`);
|
|
412
|
+
assert(manifest.exports?.[`./api/${rpc ? 'rpc-client' : 'client'}`] === `./src/api/${stem}-${rpc ? 'rpc-client' : 'client'}.ts`, `${packageFile}: invalid API client export`);
|
|
413
|
+
assert(manifest.exports?.[`./api/${rpc ? 'client' : 'rpc-client'}`] === void 0, `${packageFile}: forbidden opposite protocol client export`);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
const shell = 'apps/shell-super-app';
|
|
417
|
+
const verticals = apps.filter((app)=>app.path.startsWith('verticals/') && apiApp(app));
|
|
418
|
+
if (exists(shell) && verticals.length) {
|
|
419
|
+
const file = `${shell}/src/api/vertical-clients.ts`;
|
|
420
|
+
assert(exists(file), `${file}: must aggregate vertical API clients`);
|
|
421
|
+
if (exists(file)) guarded(file, ()=>{
|
|
422
|
+
const ast = parseFile(file);
|
|
423
|
+
for (const app of verticals){
|
|
424
|
+
const specifier = `${app.package}/api/${app.api?.protocol === 'rpc' ? 'rpc-client' : 'client'}`;
|
|
425
|
+
assert(ast?.program.body.some((item)=>__rspack_external__babel_types_6e8337dd.isExportNamedDeclaration(item) && item.source?.value === specifier), `${file}: must re-export ${specifier}`);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
if (exists(`${shell}/package.json`)) guarded(`${shell}/package.json`, ()=>assert(JSON.parse(read(`${shell}/package.json`)).exports?.['./api/clients'] === './src/api/vertical-clients.ts', `${shell}/package.json: must export ./api/clients`));
|
|
430
|
+
if (exists('topology/reference-topology.json')) guarded('topology/reference-topology.json', ()=>{
|
|
431
|
+
for (const vertical of JSON.parse(read('topology/reference-topology.json')).verticals ?? []){
|
|
432
|
+
if (vertical.api?.runtime === 'effect') {
|
|
433
|
+
assert(vertical.api.bff?.strictEffectApproach === true, `${vertical.id}: topology must mark strictEffectApproach true`);
|
|
434
|
+
assert('string' == typeof vertical.api.serverEntry && vertical.api.serverEntry.endsWith('/api/index.ts'), `${vertical.id}: topology must use api/index.ts`);
|
|
435
|
+
}
|
|
436
|
+
assert(!vertical.api?.effect, `${vertical.id}: topology must describe API directly, not api.effect`);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
} catch (error) {
|
|
440
|
+
toolErrors.push(errorMessage(error));
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
diagnostics,
|
|
444
|
+
toolErrors,
|
|
445
|
+
topologyFilesAnalyzed
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
const checkMicroVerticalApiBoundaries = (options)=>check(options, true);
|
|
449
|
+
const checkMicroVerticalApiConsumerFiles = (options)=>check(options, false);
|
|
450
|
+
export { checkMicroVerticalApiBoundaries, checkMicroVerticalApiConsumerFiles, configuredMicroVerticalApiStem, microVerticalApiBaselineViolation };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import "node:module";
|
|
2
|
+
import node_fs from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import node_path from "node:path";
|
|
5
|
+
import { parse } from "@babel/parser";
|
|
6
|
+
import * as __rspack_external__babel_types_6e8337dd from "@babel/types";
|
|
7
|
+
const baselinePackage = '@modern-js/bff-effect/microvertical-api';
|
|
8
|
+
const ownerName = '@modern-js/bff-effect';
|
|
9
|
+
const within = (root, file)=>file.startsWith(`${root}${node_path.sep}`);
|
|
10
|
+
function resolveBaselinePackageDirectory(fromFile) {
|
|
11
|
+
const lookup = createRequire(fromFile).resolve.paths(baselinePackage);
|
|
12
|
+
for (const directory of lookup ?? []){
|
|
13
|
+
const candidate = node_path.join(directory, ownerName);
|
|
14
|
+
if (!node_fs.existsSync(candidate)) continue;
|
|
15
|
+
const root = node_fs.realpathSync(candidate);
|
|
16
|
+
const manifest = JSON.parse(node_fs.readFileSync(node_path.join(root, 'package.json'), 'utf8'));
|
|
17
|
+
if (![
|
|
18
|
+
ownerName,
|
|
19
|
+
'@bleedingdev/modern-js-bff-effect'
|
|
20
|
+
].includes(manifest.name)) throw new Error(`${fromFile}: ${baselinePackage} resolved to unrelated package ${String(manifest.name)}`);
|
|
21
|
+
const target = importTarget(manifest.exports?.['./microvertical-api']);
|
|
22
|
+
if (!target?.startsWith('./')) throw new Error(`${fromFile}: ${baselinePackage} has no public Node import export`);
|
|
23
|
+
node_fs.statSync(node_path.resolve(root, target));
|
|
24
|
+
return root;
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`${fromFile}: cannot resolve installed ${baselinePackage}`);
|
|
27
|
+
}
|
|
28
|
+
function importTarget(value) {
|
|
29
|
+
if ('string' == typeof value) return value;
|
|
30
|
+
if (!value || 'object' != typeof value || Array.isArray(value)) return;
|
|
31
|
+
for (const [condition, target] of Object.entries(value))if ([
|
|
32
|
+
'node',
|
|
33
|
+
'import',
|
|
34
|
+
'default'
|
|
35
|
+
].includes(condition)) {
|
|
36
|
+
const resolved = importTarget(target);
|
|
37
|
+
if (resolved) return resolved;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function baselinePublicIdentityIsExact(fromFile, expectation) {
|
|
41
|
+
const owner = node_fs.realpathSync(expectation.baselinePackageDirectory);
|
|
42
|
+
const manifest = JSON.parse(node_fs.readFileSync(node_path.join(owner, 'package.json'), 'utf8'));
|
|
43
|
+
if (expectation.baselinePackage !== baselinePackage || ![
|
|
44
|
+
ownerName,
|
|
45
|
+
'@bleedingdev/modern-js-bff-effect'
|
|
46
|
+
].includes(manifest.name)) return false;
|
|
47
|
+
let resolvedOwner;
|
|
48
|
+
try {
|
|
49
|
+
resolvedOwner = resolveBaselinePackageDirectory(fromFile);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof Error && error.message.includes('resolved to unrelated package')) return false;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
if (resolvedOwner !== owner) return false;
|
|
55
|
+
const target = importTarget(manifest.exports?.['./microvertical-api']);
|
|
56
|
+
if (!target?.startsWith('./')) return false;
|
|
57
|
+
const modules = new Map();
|
|
58
|
+
const load = (filename)=>{
|
|
59
|
+
const real = node_fs.realpathSync(filename);
|
|
60
|
+
if (!within(owner, real)) return;
|
|
61
|
+
let file = modules.get(real);
|
|
62
|
+
if (!file) {
|
|
63
|
+
if (modules.size >= 64 || node_fs.statSync(real).size > 1000000) throw new Error(`${real}: public export analysis budget exceeded`);
|
|
64
|
+
file = parse(node_fs.readFileSync(real, 'utf8'), {
|
|
65
|
+
sourceType: 'module',
|
|
66
|
+
sourceFilename: real,
|
|
67
|
+
plugins: [
|
|
68
|
+
"typescript"
|
|
69
|
+
]
|
|
70
|
+
});
|
|
71
|
+
modules.set(real, file);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
file,
|
|
75
|
+
real
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
const follow = (from, specifier)=>{
|
|
79
|
+
if (!specifier.startsWith('.')) return;
|
|
80
|
+
const candidate = node_path.resolve(node_path.dirname(from), specifier);
|
|
81
|
+
if (!within(owner, candidate)) return;
|
|
82
|
+
return [
|
|
83
|
+
candidate,
|
|
84
|
+
`${candidate}.ts`,
|
|
85
|
+
`${candidate}.js`,
|
|
86
|
+
node_path.join(candidate, 'index.ts'),
|
|
87
|
+
node_path.join(candidate, 'index.js')
|
|
88
|
+
].find((file)=>node_fs.existsSync(file) && node_fs.statSync(file).isFile());
|
|
89
|
+
};
|
|
90
|
+
const exportedOwner = (filename, name, seen = new Set())=>{
|
|
91
|
+
const loaded = load(filename);
|
|
92
|
+
if (!loaded) return;
|
|
93
|
+
const { file, real } = loaded;
|
|
94
|
+
const key = `${real}:${name}`;
|
|
95
|
+
if (seen.has(key)) return;
|
|
96
|
+
seen = new Set([
|
|
97
|
+
...seen,
|
|
98
|
+
key
|
|
99
|
+
]);
|
|
100
|
+
const explicit = [];
|
|
101
|
+
const stars = [];
|
|
102
|
+
for (const statement of file.program.body){
|
|
103
|
+
if (__rspack_external__babel_types_6e8337dd.isExportAllDeclaration(statement) && 'type' !== statement.exportKind) {
|
|
104
|
+
const next = follow(real, statement.source.value);
|
|
105
|
+
if (!next) return;
|
|
106
|
+
stars.push(next);
|
|
107
|
+
}
|
|
108
|
+
if (!__rspack_external__babel_types_6e8337dd.isExportNamedDeclaration(statement) || 'type' === statement.exportKind) continue;
|
|
109
|
+
const declaration = statement.declaration;
|
|
110
|
+
if (__rspack_external__babel_types_6e8337dd.isVariableDeclaration(declaration) && declaration.declarations.some((value)=>__rspack_external__babel_types_6e8337dd.isIdentifier(value.id, {
|
|
111
|
+
name
|
|
112
|
+
}))) explicit.push(real);
|
|
113
|
+
if ((__rspack_external__babel_types_6e8337dd.isFunctionDeclaration(declaration) || __rspack_external__babel_types_6e8337dd.isClassDeclaration(declaration)) && __rspack_external__babel_types_6e8337dd.isIdentifier(declaration.id, {
|
|
114
|
+
name
|
|
115
|
+
})) explicit.push(real);
|
|
116
|
+
for (const value of statement.specifiers)if (__rspack_external__babel_types_6e8337dd.isExportSpecifier(value) && 'type' !== value.exportKind && __rspack_external__babel_types_6e8337dd.isIdentifier(value.exported, {
|
|
117
|
+
name
|
|
118
|
+
})) {
|
|
119
|
+
if (!__rspack_external__babel_types_6e8337dd.isIdentifier(value.local, {
|
|
120
|
+
name
|
|
121
|
+
})) {
|
|
122
|
+
explicit.push(void 0);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (statement.source) {
|
|
126
|
+
const next = follow(real, statement.source.value);
|
|
127
|
+
explicit.push(next ? exportedOwner(next, name, seen) : void 0);
|
|
128
|
+
} else {
|
|
129
|
+
const local = file.program.body.find((item)=>__rspack_external__babel_types_6e8337dd.isVariableDeclaration(item) && item.declarations.some((value)=>__rspack_external__babel_types_6e8337dd.isIdentifier(value.id, {
|
|
130
|
+
name
|
|
131
|
+
})) || (__rspack_external__babel_types_6e8337dd.isFunctionDeclaration(item) || __rspack_external__babel_types_6e8337dd.isClassDeclaration(item)) && __rspack_external__babel_types_6e8337dd.isIdentifier(item.id, {
|
|
132
|
+
name
|
|
133
|
+
}));
|
|
134
|
+
if (local) {
|
|
135
|
+
explicit.push(real);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const imported = file.program.body.find((item)=>__rspack_external__babel_types_6e8337dd.isImportDeclaration(item) && 'type' !== item.importKind && item.specifiers.some((value)=>__rspack_external__babel_types_6e8337dd.isImportSpecifier(value) && 'type' !== value.importKind && __rspack_external__babel_types_6e8337dd.isIdentifier(value.local, {
|
|
139
|
+
name
|
|
140
|
+
}) && __rspack_external__babel_types_6e8337dd.isIdentifier(value.imported, {
|
|
141
|
+
name
|
|
142
|
+
})));
|
|
143
|
+
const next = __rspack_external__babel_types_6e8337dd.isImportDeclaration(imported) ? follow(real, imported.source.value) : void 0;
|
|
144
|
+
explicit.push(next ? exportedOwner(next, name, seen) : void 0);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (explicit.length) return 1 === explicit.length ? explicit[0] : void 0;
|
|
149
|
+
const owners = stars.map((next)=>exportedOwner(next, name, seen));
|
|
150
|
+
const unique = new Set(owners.filter((value)=>void 0 !== value));
|
|
151
|
+
return 1 === unique.size ? [
|
|
152
|
+
...unique
|
|
153
|
+
][0] : void 0;
|
|
154
|
+
};
|
|
155
|
+
const entry = node_path.resolve(owner, target);
|
|
156
|
+
const owners = [
|
|
157
|
+
'MicroVerticalBuildMarkerSchema',
|
|
158
|
+
'MicroVerticalReadinessSchema',
|
|
159
|
+
'createMicroVerticalOperationContext'
|
|
160
|
+
].map((name)=>exportedOwner(entry, name));
|
|
161
|
+
return void 0 !== owners[0] && owners.every((value)=>value === owners[0]);
|
|
162
|
+
}
|
|
163
|
+
export { baselinePackage, baselinePublicIdentityIsExact, resolveBaselinePackageDirectory };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import "node:module";
|
|
2
|
+
import { createEffectApiImportResolver, strictEffectRuntimeTopologyViolation } from "../../strict-effect-runtime.js";
|
|
2
3
|
import { getSourceText, isApiEntryFile, isApiSourceFile, isForbiddenGeneratedEffectPath, isSharedApiContractFile, normalizeFilename, reportMissingProgramPattern, reportProgramPattern } from "../ast.js";
|
|
3
4
|
const createStrictEffectApiBoundariesRule = ()=>({
|
|
4
5
|
meta: {
|
|
@@ -18,15 +19,16 @@ const createStrictEffectApiBoundariesRule = ()=>({
|
|
|
18
19
|
node,
|
|
19
20
|
message: 'Generated UltraModern workspaces use direct api/index.ts, shared/api.ts and src/api/* paths; api/effect, api/lambda, shared/effect and src/effect are forbidden.'
|
|
20
21
|
});
|
|
21
|
-
reportProgramPattern(context, node, source, /@modern-js\/plugin-bff\/hono-server/u, 'UltraModern API workspaces must not import Hono server helpers; use @modern-js/
|
|
22
|
+
reportProgramPattern(context, node, source, /@modern-js\/plugin-bff\/(?:server|hono-server)(?![\w-])/u, 'UltraModern API workspaces must not import Hono server helpers; use @modern-js/bff-effect/effect-edge and HttpApi.');
|
|
22
23
|
reportProgramPattern(context, node, source, /\bruntimeFramework\s*(?::|=)\s*['"]hono['"]/u, 'UltraModern API apps must use bff.runtimeFramework: "effect".');
|
|
23
24
|
reportProgramPattern(context, node, source, /(?:from|import)\s*['"][^'"]*(?:api\/effect|shared\/effect)[^'"]*['"]|shared-effect-api/u, 'Import API code from direct api/index.ts, shared/api.ts or src/api/* paths, not api/effect, shared/effect or shared Effect API packages.');
|
|
24
25
|
reportProgramPattern(context, node, source, /\bstrictEffectApproach\s*(?::|=)\s*false\b/u, 'UltraModern API apps must keep strictEffectApproach enabled.');
|
|
25
26
|
if (isApiEntryFile(filename)) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
const violation = strictEffectRuntimeTopologyViolation(source, createEffectApiImportResolver(filename));
|
|
28
|
+
if (violation) context.report({
|
|
29
|
+
node,
|
|
30
|
+
message: violation
|
|
31
|
+
});
|
|
30
32
|
}
|
|
31
33
|
if (isSharedApiContractFile(filename)) {
|
|
32
34
|
reportMissingProgramPattern(context, node, source, /\bHttpApi\.make\b/u, 'Generated shared API contracts must declare an HttpApi contract.');
|