@bleedingdev/modern-js-code-tools 3.9.0-ultramodern.9 → 3.9.3-ultramodern.1
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 -1
- package/bin/modern-api-check.mjs +1 -1
- package/bin/modern-i18n-check.mjs +4 -0
- package/dist/cjs/cli/microvertical-api-check.cjs +36 -7
- package/dist/cjs/cli/workspace-source-check.cjs +90 -17
- package/dist/cjs/microvertical-api-baseline.cjs +81 -244
- package/dist/cjs/microvertical-api-boundary.cjs +116 -38
- package/dist/cjs/microvertical-api-owner.cjs +5 -2
- package/dist/cjs/module-graph.cjs +1820 -0
- package/dist/cjs/oxlint-plugin/rules/strict-effect-api-boundaries.cjs +1 -1
- package/dist/cjs/source-analysis.cjs +108 -0
- package/dist/cjs/strict-effect-runtime.cjs +16 -51
- package/dist/esm/cli/microvertical-api-check.js +26 -7
- package/dist/esm/cli/workspace-source-check.js +86 -13
- package/dist/esm/microvertical-api-baseline.js +66 -214
- package/dist/esm/microvertical-api-boundary.js +114 -39
- package/dist/esm/microvertical-api-owner.js +5 -2
- package/dist/esm/module-graph.js +1762 -0
- package/dist/esm/oxlint-plugin/rules/strict-effect-api-boundaries.js +1 -1
- package/dist/esm/source-analysis.js +48 -0
- package/dist/esm/strict-effect-runtime.js +16 -50
- package/dist/esm-node/cli/microvertical-api-check.js +26 -7
- package/dist/esm-node/cli/workspace-source-check.js +86 -13
- package/dist/esm-node/microvertical-api-baseline.js +66 -214
- package/dist/esm-node/microvertical-api-boundary.js +114 -39
- package/dist/esm-node/microvertical-api-owner.js +5 -2
- package/dist/esm-node/module-graph.js +1763 -0
- package/dist/esm-node/oxlint-plugin/rules/strict-effect-api-boundaries.js +1 -1
- package/dist/esm-node/source-analysis.js +49 -0
- package/dist/esm-node/strict-effect-runtime.js +16 -50
- package/dist/types/cli/microvertical-api-check.d.ts +1 -1
- package/dist/types/cli/workspace-source-check.d.ts +1 -1
- package/dist/types/microvertical-api-baseline.d.ts +0 -5
- package/dist/types/microvertical-api-boundary.d.ts +15 -1
- package/dist/types/microvertical-api-owner.d.ts +1 -1
- package/dist/types/module-graph.d.ts +92 -0
- package/dist/types/source-analysis.d.ts +13 -0
- package/package.json +17 -13
|
@@ -52,7 +52,7 @@ const createStrictEffectApiBoundariesRule = ()=>({
|
|
|
52
52
|
});
|
|
53
53
|
(0, external_ast_cjs_namespaceObject.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.');
|
|
54
54
|
(0, external_ast_cjs_namespaceObject.reportProgramPattern)(context, node, source, /\bruntimeFramework\s*(?::|=)\s*['"]hono['"]/u, 'UltraModern API apps must use bff.runtimeFramework: "effect".');
|
|
55
|
-
(0, external_ast_cjs_namespaceObject.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.');
|
|
55
|
+
(0, external_ast_cjs_namespaceObject.reportProgramPattern)(context, node, source, /(?:from|import)\s*['"][^'"]*(?:api\/effect|shared\/effect)(?=\/|\.[cm]?[jt]sx?['"]|['"])[^'"]*['"]|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.');
|
|
56
56
|
(0, external_ast_cjs_namespaceObject.reportProgramPattern)(context, node, source, /\bstrictEffectApproach\s*(?::|=)\s*false\b/u, 'UltraModern API apps must keep strictEffectApproach enabled.');
|
|
57
57
|
if ((0, external_ast_cjs_namespaceObject.isApiEntryFile)(filename)) {
|
|
58
58
|
const violation = (0, external_strict_effect_runtime_cjs_namespaceObject.strictEffectRuntimeTopologyViolation)(source, (0, external_strict_effect_runtime_cjs_namespaceObject.createEffectApiImportResolver)(filename));
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __webpack_require__ = {};
|
|
3
|
+
(()=>{
|
|
4
|
+
__webpack_require__.n = (module)=>{
|
|
5
|
+
var getter = module && module.__esModule ? ()=>module['default'] : ()=>module;
|
|
6
|
+
__webpack_require__.d(getter, {
|
|
7
|
+
a: getter
|
|
8
|
+
});
|
|
9
|
+
return getter;
|
|
10
|
+
};
|
|
11
|
+
})();
|
|
12
|
+
(()=>{
|
|
13
|
+
__webpack_require__.d = (exports1, getters, values)=>{
|
|
14
|
+
var define = (defs, kind)=>{
|
|
15
|
+
for(var key in defs)if (__webpack_require__.o(defs, key) && !__webpack_require__.o(exports1, key)) Object.defineProperty(exports1, key, {
|
|
16
|
+
enumerable: true,
|
|
17
|
+
[kind]: defs[key]
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
define(getters, "get");
|
|
21
|
+
define(values, "value");
|
|
22
|
+
};
|
|
23
|
+
})();
|
|
24
|
+
(()=>{
|
|
25
|
+
__webpack_require__.o = (obj, prop)=>Object.prototype.hasOwnProperty.call(obj, prop);
|
|
26
|
+
})();
|
|
27
|
+
(()=>{
|
|
28
|
+
__webpack_require__.r = (exports1)=>{
|
|
29
|
+
if ("u" > typeof Symbol && Symbol.toStringTag) Object.defineProperty(exports1, Symbol.toStringTag, {
|
|
30
|
+
value: 'Module'
|
|
31
|
+
});
|
|
32
|
+
Object.defineProperty(exports1, '__esModule', {
|
|
33
|
+
value: true
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
})();
|
|
37
|
+
var __webpack_exports__ = {};
|
|
38
|
+
__webpack_require__.r(__webpack_exports__);
|
|
39
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
40
|
+
SourceSyntaxError: ()=>SourceSyntaxError,
|
|
41
|
+
consumerParserPlugins: ()=>consumerParserPlugins,
|
|
42
|
+
parseSource: ()=>parseSource,
|
|
43
|
+
traverseSource: ()=>traverseSource,
|
|
44
|
+
unwrapExpression: ()=>unwrapExpression
|
|
45
|
+
});
|
|
46
|
+
const parser_namespaceObject = require("@babel/parser");
|
|
47
|
+
const traverse_namespaceObject = require("@babel/traverse");
|
|
48
|
+
var traverse_default = /*#__PURE__*/ __webpack_require__.n(traverse_namespaceObject);
|
|
49
|
+
const types_namespaceObject = require("@babel/types");
|
|
50
|
+
const consumerParserPlugins = (filePath)=>/\.[jt]sx$/u.test(filePath) ? [
|
|
51
|
+
"typescript",
|
|
52
|
+
'decorators',
|
|
53
|
+
'decoratorAutoAccessors',
|
|
54
|
+
'jsx'
|
|
55
|
+
] : [
|
|
56
|
+
"typescript",
|
|
57
|
+
'decorators',
|
|
58
|
+
'decoratorAutoAccessors'
|
|
59
|
+
];
|
|
60
|
+
class SourceSyntaxError extends Error {
|
|
61
|
+
}
|
|
62
|
+
class SourceValidationHub extends traverse_namespaceObject.Hub {
|
|
63
|
+
buildError(_node, message) {
|
|
64
|
+
return new SourceSyntaxError(message);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function parseSource(source, filename, syntaxMessage) {
|
|
68
|
+
try {
|
|
69
|
+
return (0, parser_namespaceObject.parse)(source, {
|
|
70
|
+
sourceType: 'module',
|
|
71
|
+
sourceFilename: filename,
|
|
72
|
+
plugins: consumerParserPlugins(filename)
|
|
73
|
+
});
|
|
74
|
+
} catch (cause) {
|
|
75
|
+
if ('object' == typeof cause && null !== cause && 'code' in cause && 'BABEL_PARSER_SYNTAX_ERROR' === cause.code && 'reasonCode' in cause && 'string' == typeof cause.reasonCode) throw new SourceSyntaxError(syntaxMessage ?? (cause instanceof Error ? cause.message : String(cause)));
|
|
76
|
+
throw cause;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function traverseSource(file, visitor) {
|
|
80
|
+
const program = traverse_namespaceObject.NodePath.get({
|
|
81
|
+
hub: new SourceValidationHub(),
|
|
82
|
+
parentPath: void 0,
|
|
83
|
+
parent: file,
|
|
84
|
+
container: file,
|
|
85
|
+
key: 'program'
|
|
86
|
+
});
|
|
87
|
+
program.setContext();
|
|
88
|
+
traverse_default()(file, visitor, program.scope, void 0, program);
|
|
89
|
+
}
|
|
90
|
+
function unwrapExpression(node, includeAssertions = false) {
|
|
91
|
+
while(types_namespaceObject.isTSAsExpression(node) || types_namespaceObject.isParenthesizedExpression(node) || types_namespaceObject.isTSSatisfiesExpression(node) || includeAssertions && (types_namespaceObject.isTSTypeAssertion(node) || types_namespaceObject.isTSNonNullExpression(node) || types_namespaceObject.isTSInstantiationExpression(node)))node = node.expression;
|
|
92
|
+
return node;
|
|
93
|
+
}
|
|
94
|
+
exports.SourceSyntaxError = __webpack_exports__.SourceSyntaxError;
|
|
95
|
+
exports.consumerParserPlugins = __webpack_exports__.consumerParserPlugins;
|
|
96
|
+
exports.parseSource = __webpack_exports__.parseSource;
|
|
97
|
+
exports.traverseSource = __webpack_exports__.traverseSource;
|
|
98
|
+
exports.unwrapExpression = __webpack_exports__.unwrapExpression;
|
|
99
|
+
for(var __rspack_i in __webpack_exports__)if (-1 === [
|
|
100
|
+
"SourceSyntaxError",
|
|
101
|
+
"consumerParserPlugins",
|
|
102
|
+
"parseSource",
|
|
103
|
+
"traverseSource",
|
|
104
|
+
"unwrapExpression"
|
|
105
|
+
].indexOf(__rspack_i)) exports[__rspack_i] = __webpack_exports__[__rspack_i];
|
|
106
|
+
Object.defineProperty(exports, '__esModule', {
|
|
107
|
+
value: true
|
|
108
|
+
});
|
|
@@ -44,25 +44,13 @@ const external_node_fs_namespaceObject = require("node:fs");
|
|
|
44
44
|
var external_node_fs_default = /*#__PURE__*/ __webpack_require__.n(external_node_fs_namespaceObject);
|
|
45
45
|
const external_node_path_namespaceObject = require("node:path");
|
|
46
46
|
var external_node_path_default = /*#__PURE__*/ __webpack_require__.n(external_node_path_namespaceObject);
|
|
47
|
-
const parser_namespaceObject = require("@babel/parser");
|
|
48
|
-
const traverse_namespaceObject = require("@babel/traverse");
|
|
49
|
-
var traverse_default = /*#__PURE__*/ __webpack_require__.n(traverse_namespaceObject);
|
|
50
47
|
const types_namespaceObject = require("@babel/types");
|
|
48
|
+
const external_source_analysis_cjs_namespaceObject = require("./source-analysis.cjs");
|
|
51
49
|
const edge = '@modern-js/bff-effect/effect-edge';
|
|
52
50
|
const nodeRuntime = '@modern-js/bff-effect/effect';
|
|
53
|
-
const
|
|
54
|
-
const failure = 'Generated API entries must export defineEffectBff(...) or the
|
|
55
|
-
const
|
|
56
|
-
function invalidSource(message) {
|
|
57
|
-
const error = new Error(message);
|
|
58
|
-
invalidSources.add(error);
|
|
59
|
-
return error;
|
|
60
|
-
}
|
|
61
|
-
class SourceValidationHub extends traverse_namespaceObject.Hub {
|
|
62
|
-
buildError(_node, message) {
|
|
63
|
-
return invalidSource(message);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
51
|
+
const assembly = '@modern-js/bff-effect/assembly';
|
|
52
|
+
const failure = 'Generated API entries must export defineEffectBff(...) or the native Effect BFF assembly helper with an explicitly composed handler Layer and an unshadowed executable root; entries must implement handlers through HttpApiBuilder.group.';
|
|
53
|
+
const MAX_API_SOURCE_MODULES = 256;
|
|
66
54
|
function createEffectApiImportResolver(filename) {
|
|
67
55
|
const directory = external_node_fs_default().realpathSync(external_node_path_default().dirname(filename));
|
|
68
56
|
const owner = external_node_fs_default().realpathSync(external_node_path_default().resolve(directory, '..'));
|
|
@@ -94,34 +82,14 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
|
|
|
94
82
|
const load = (input)=>{
|
|
95
83
|
const existing = modules.get(input.id);
|
|
96
84
|
if (existing) return existing;
|
|
97
|
-
if (modules.size >=
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
file = (0, parser_namespaceObject.parse)(input.source, {
|
|
101
|
-
sourceType: 'module',
|
|
102
|
-
sourceFilename: input.id,
|
|
103
|
-
plugins: [
|
|
104
|
-
"typescript"
|
|
105
|
-
]
|
|
106
|
-
});
|
|
107
|
-
} catch (cause) {
|
|
108
|
-
if ('object' == typeof cause && null !== cause && 'code' in cause && 'BABEL_PARSER_SYNTAX_ERROR' === cause.code && 'reasonCode' in cause && 'string' == typeof cause.reasonCode) throw invalidSource('Invalid API syntax');
|
|
109
|
-
throw cause;
|
|
110
|
-
}
|
|
85
|
+
if (modules.size >= MAX_API_SOURCE_MODULES || input.source.length > 1000000) throw new external_source_analysis_cjs_namespaceObject.SourceSyntaxError('API source budget exceeded');
|
|
86
|
+
const file = (0, external_source_analysis_cjs_namespaceObject.parseSource)(input.source, input.id, 'Invalid API syntax');
|
|
111
87
|
const module = {
|
|
112
88
|
...input,
|
|
113
89
|
file
|
|
114
90
|
};
|
|
115
91
|
modules.set(input.id, module);
|
|
116
|
-
|
|
117
|
-
hub: new SourceValidationHub(),
|
|
118
|
-
parentPath: void 0,
|
|
119
|
-
parent: file,
|
|
120
|
-
container: file,
|
|
121
|
-
key: 'program'
|
|
122
|
-
});
|
|
123
|
-
program.setContext();
|
|
124
|
-
traverse_default()(file, {
|
|
92
|
+
(0, external_source_analysis_cjs_namespaceObject.traverseSource)(file, {
|
|
125
93
|
enter (nodePath) {
|
|
126
94
|
paths.set(nodePath.node, nodePath);
|
|
127
95
|
owners.set(nodePath.node, module);
|
|
@@ -140,7 +108,7 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
|
|
|
140
108
|
if (binding) mutated.add(binding);
|
|
141
109
|
}
|
|
142
110
|
}
|
|
143
|
-
}
|
|
111
|
+
});
|
|
144
112
|
return module;
|
|
145
113
|
};
|
|
146
114
|
try {
|
|
@@ -149,10 +117,7 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
|
|
|
149
117
|
source,
|
|
150
118
|
resolveImport
|
|
151
119
|
});
|
|
152
|
-
const unwrap = (node)=>
|
|
153
|
-
while(types_namespaceObject.isParenthesizedExpression(node) || types_namespaceObject.isTSSatisfiesExpression(node) || types_namespaceObject.isTSAsExpression(node) || types_namespaceObject.isTSTypeAssertion(node) || types_namespaceObject.isTSNonNullExpression(node))node = node.expression;
|
|
154
|
-
return node;
|
|
155
|
-
};
|
|
120
|
+
const unwrap = (node)=>(0, external_source_analysis_cjs_namespaceObject.unwrapExpression)(node, true);
|
|
156
121
|
const binding = (node)=>{
|
|
157
122
|
node = unwrap(node);
|
|
158
123
|
if (!types_namespaceObject.isIdentifier(node)) return;
|
|
@@ -177,15 +142,15 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
|
|
|
177
142
|
const namespaceSources = {
|
|
178
143
|
HttpApi: [
|
|
179
144
|
edge,
|
|
180
|
-
'effect/
|
|
145
|
+
'effect/http-api'
|
|
181
146
|
],
|
|
182
147
|
HttpApiBuilder: [
|
|
183
148
|
edge,
|
|
184
|
-
'effect/
|
|
149
|
+
'effect/http-api'
|
|
185
150
|
],
|
|
186
151
|
HttpRouter: [
|
|
187
152
|
edge,
|
|
188
|
-
'effect/
|
|
153
|
+
'effect/http'
|
|
189
154
|
]
|
|
190
155
|
};
|
|
191
156
|
const native = (node, name, sources)=>{
|
|
@@ -281,7 +246,7 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
|
|
|
281
246
|
if (!types_namespaceObject.isBlockStatement(body)) return body;
|
|
282
247
|
const statements = body.body;
|
|
283
248
|
const last = statements.at(-1);
|
|
284
|
-
return statements.slice(0, -1).every((statement)=>types_namespaceObject.isVariableDeclaration(statement)) && types_namespaceObject.isReturnStatement(last) ? last.argument : void 0;
|
|
249
|
+
return statements.slice(0, -1).every((statement)=>types_namespaceObject.isVariableDeclaration(statement) || types_namespaceObject.isTSTypeAliasDeclaration(statement) || types_namespaceObject.isTSInterfaceDeclaration(statement)) && types_namespaceObject.isReturnStatement(last) ? last.argument : void 0;
|
|
285
250
|
};
|
|
286
251
|
const pipe = (node, check)=>types_namespaceObject.isMemberExpression(node.callee) && !node.callee.computed && types_namespaceObject.isIdentifier(node.callee.property, {
|
|
287
252
|
name: 'pipe'
|
|
@@ -346,7 +311,7 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
|
|
|
346
311
|
const groupValue = externalValue(group);
|
|
347
312
|
const groupInit = groupValue && initialized(groupValue);
|
|
348
313
|
if (!groupInit || !types_namespaceObject.isCallExpression(groupInit) || !method(groupInit.callee, 'RpcGroup', 'make', [
|
|
349
|
-
'effect/
|
|
314
|
+
'effect/rpc',
|
|
350
315
|
'@modern-js/bff-effect/effect-client'
|
|
351
316
|
])) return false;
|
|
352
317
|
const layerInit = initialized(layer) ?? unwrap(layer);
|
|
@@ -370,7 +335,7 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
|
|
|
370
335
|
if (value?.name === 'defineEffectBff' && [
|
|
371
336
|
edge,
|
|
372
337
|
nodeRuntime
|
|
373
|
-
].includes(value.specifier) || value?.name === 'assembleEffectBffRuntime' &&
|
|
338
|
+
].includes(value.specifier) || value?.name === 'assembleEffectBffRuntime' && value.specifier === assembly) {
|
|
374
339
|
if (1 !== node.arguments.length) return false;
|
|
375
340
|
const values = properties(node.arguments[0]);
|
|
376
341
|
if (!values) return false;
|
|
@@ -393,7 +358,7 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
|
|
|
393
358
|
const root = entry.file.program.body.find((statement)=>types_namespaceObject.isExportDefaultDeclaration(statement));
|
|
394
359
|
return root && runtime(root.declaration) ? void 0 : failure;
|
|
395
360
|
} catch (cause) {
|
|
396
|
-
if (
|
|
361
|
+
if (cause instanceof external_source_analysis_cjs_namespaceObject.SourceSyntaxError) return failure;
|
|
397
362
|
const message = 'object' == typeof cause && null !== cause && 'message' in cause && 'string' == typeof cause.message ? cause.message : String(cause);
|
|
398
363
|
throw new Error(`Strict Effect API analysis failed: ${message}`, {
|
|
399
364
|
cause
|
|
@@ -1,27 +1,46 @@
|
|
|
1
|
+
import node_path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
1
3
|
import { checkMicroVerticalApiBoundaries, checkMicroVerticalApiConsumerFiles } from "../microvertical-api-boundary.js";
|
|
2
|
-
|
|
4
|
+
const flags = [
|
|
5
|
+
'--workspace-root',
|
|
6
|
+
'--baseline-package-directory',
|
|
7
|
+
'--rules'
|
|
8
|
+
];
|
|
9
|
+
async function loadSourceRules(file) {
|
|
10
|
+
const rules = (await import(pathToFileURL(node_path.resolve(file)).href)).default;
|
|
11
|
+
if (!Array.isArray(rules) || !rules.every((rule)=>'function' == typeof rule)) throw new Error(`${file}: default export must be an array of source rule functions, e.g. \`export default [myRule]\``);
|
|
12
|
+
return rules;
|
|
13
|
+
}
|
|
14
|
+
async function runMicroVerticalApiCheckCli(args = process.argv.slice(2), filesOnly = false) {
|
|
3
15
|
let workspaceRoot = process.env.ULTRAMODERN_WORKSPACE_ROOT ?? process.cwd();
|
|
4
16
|
let baselinePackageDirectory;
|
|
17
|
+
const ruleFiles = [];
|
|
5
18
|
for(let index = 0; index < args.length; index += 1){
|
|
6
19
|
const argument = args[index];
|
|
7
20
|
if ('--help' === argument || '-h' === argument) {
|
|
8
|
-
console.log(`${filesOnly ? 'modern-api-check-files' : 'modern-api-check'} [--workspace-root <path>] [--baseline-package-directory <path>]
|
|
21
|
+
console.log(`${filesOnly ? 'modern-api-check-files' : 'modern-api-check'} [--workspace-root <path>] [--baseline-package-directory <path>] [--rules <module>]...\n${filesOnly ? 'Checks consumer files, contracts, package exports and owner identity. Does not analyze runtime topology; run the strict Effect source phase separately.' : 'Checks consumer files, contracts, package exports, owner identity and runtime topology once per API entry.'}\nThe baseline owner defaults to each contract's native package resolver. --baseline-package-directory requires that exact installed owner for every contract.\n--rules loads a module whose default export is an array of source rules; each rule runs once per workspace source file with the shared module graph.\nWorkspace defaults to ULTRAMODERN_WORKSPACE_ROOT then cwd. Exit codes: 0 valid, 1 consumer violation, 2 tool/configuration failure.`);
|
|
9
22
|
return 0;
|
|
10
23
|
}
|
|
11
|
-
if (![
|
|
12
|
-
'--workspace-root',
|
|
13
|
-
'--baseline-package-directory'
|
|
14
|
-
].includes(argument ?? '') || !args[index + 1] || args[index + 1]?.startsWith('--')) {
|
|
24
|
+
if (!flags.includes(argument ?? '') || !args[index + 1] || args[index + 1]?.startsWith('--')) {
|
|
15
25
|
console.error(`Invalid argument: ${argument ?? ''}. Use --help.`);
|
|
16
26
|
return 2;
|
|
17
27
|
}
|
|
18
28
|
const value = args[++index];
|
|
19
29
|
if ('--workspace-root' === argument) workspaceRoot = value;
|
|
30
|
+
else if ('--rules' === argument) ruleFiles.push(value);
|
|
20
31
|
else baselinePackageDirectory = value;
|
|
21
32
|
}
|
|
33
|
+
const sourceRules = [];
|
|
34
|
+
for (const file of ruleFiles)try {
|
|
35
|
+
sourceRules.push(...await loadSourceRules(file));
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error(`API tool error: --rules ${error instanceof Error ? error.message : String(error)}`);
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
22
40
|
const result = (filesOnly ? checkMicroVerticalApiConsumerFiles : checkMicroVerticalApiBoundaries)({
|
|
23
41
|
workspaceRoot,
|
|
24
|
-
baselinePackageDirectory
|
|
42
|
+
baselinePackageDirectory,
|
|
43
|
+
sourceRules
|
|
25
44
|
});
|
|
26
45
|
for (const diagnostic of result.diagnostics)console.error(`API violation: ${diagnostic}`);
|
|
27
46
|
for (const error of result.toolErrors)console.error(`API tool error: ${error}`);
|
|
@@ -6,6 +6,11 @@ const DEFAULT_LOCALES = [
|
|
|
6
6
|
'en',
|
|
7
7
|
'cs'
|
|
8
8
|
];
|
|
9
|
+
const DEFAULT_SOURCE_ROOTS = [
|
|
10
|
+
'apps',
|
|
11
|
+
'verticals',
|
|
12
|
+
'packages'
|
|
13
|
+
];
|
|
9
14
|
const ignoredDirectories = new Set([
|
|
10
15
|
'.modern',
|
|
11
16
|
'.modernjs',
|
|
@@ -39,6 +44,8 @@ const createLocaleJsonMatcher = (locales)=>{
|
|
|
39
44
|
const resolvePluralCategories = (locale, overrides)=>overrides?.[locale] ?? new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
|
|
40
45
|
const readText = (filePath)=>node_fs.readFileSync(filePath, 'utf-8');
|
|
41
46
|
const localeImportPattern = (locale)=>new RegExp(`import\\s+(?:\\*\\s+as\\s+)?[A-Za-z_$][\\w$]*\\s+from\\s+['"]\\.\\./locales/${escapeRegExp(locale)}/[^'"]+\\.json['"]`, 'u');
|
|
47
|
+
class SourceViolation extends Error {
|
|
48
|
+
}
|
|
42
49
|
const checkRuntimeResources = (root, filePath, text, locales)=>{
|
|
43
50
|
const relative = relativePath(root, filePath);
|
|
44
51
|
if (!relative.endsWith('/src/modern.runtime.ts')) return;
|
|
@@ -46,7 +53,7 @@ const checkRuntimeResources = (root, filePath, text, locales)=>{
|
|
|
46
53
|
const registersResources = /initOptions\s*:\s*\{[\s\S]*?\bresources\s*[,:}]/u.test(text);
|
|
47
54
|
if (missingLocales.length > 0 || !registersResources) {
|
|
48
55
|
const detail = missingLocales.length > 0 ? `missing locale JSON imports for: ${missingLocales.join(', ')}` : 'initOptions does not register a `resources` entry';
|
|
49
|
-
throw new
|
|
56
|
+
throw new SourceViolation(`${relative} must register locale JSON resources in modern.runtime.ts so Worker SSR and hydration use the same first-render translations (${detail}).`);
|
|
50
57
|
}
|
|
51
58
|
};
|
|
52
59
|
const visitLocaleKeys = (value, visitor, pathParts = [])=>{
|
|
@@ -66,7 +73,7 @@ const checkPluralResources = (root, filePath, json, requiredSuffixes, pluralSuff
|
|
|
66
73
|
visitLocaleKeys(json, (key, value, pathParts)=>{
|
|
67
74
|
if ('string' != typeof value || !value.includes('{{count}}')) return;
|
|
68
75
|
const suffixMatch = key.match(pluralSuffixPattern);
|
|
69
|
-
if (!suffixMatch) throw new
|
|
76
|
+
if (!suffixMatch) throw new SourceViolation(`${relative} key ${pathParts.join('.')} contains {{count}} but is not plural-suffixed.`);
|
|
70
77
|
const [, base = '', suffix = ''] = suffixMatch;
|
|
71
78
|
const parentPath = pathParts.slice(0, -1).join('.');
|
|
72
79
|
const groupKey = `${parentPath}.${base}`;
|
|
@@ -74,7 +81,7 @@ const checkPluralResources = (root, filePath, json, requiredSuffixes, pluralSuff
|
|
|
74
81
|
existing.add(suffix);
|
|
75
82
|
groups.set(groupKey, existing);
|
|
76
83
|
});
|
|
77
|
-
for (const [group, suffixes] of groups)for (const suffix of requiredSuffixes)if (!suffixes.has(suffix)) throw new
|
|
84
|
+
for (const [group, suffixes] of groups)for (const suffix of requiredSuffixes)if (!suffixes.has(suffix)) throw new SourceViolation(`${relative} plural group ${group} is missing _${suffix}.`);
|
|
78
85
|
};
|
|
79
86
|
const runRuntimeAndLocaleResourceChecks = (root, sourceRoots, locales, pluralCategories)=>{
|
|
80
87
|
if (0 === locales.length) return;
|
|
@@ -96,11 +103,7 @@ const runRuntimeAndLocaleResourceChecks = (root, sourceRoots, locales, pluralCat
|
|
|
96
103
|
checkPluralResources(root, filePath, JSON.parse(readText(filePath)), localeCategories.get(language) ?? [], pluralSuffixPattern);
|
|
97
104
|
}
|
|
98
105
|
};
|
|
99
|
-
const runWorkspaceSourceCheck = ({ cwd = process.cwd(), sourceRoots =
|
|
100
|
-
'apps',
|
|
101
|
-
'verticals',
|
|
102
|
-
'packages'
|
|
103
|
-
], locales = DEFAULT_LOCALES, pluralCategories } = {})=>{
|
|
106
|
+
const runWorkspaceSourceCheck = ({ cwd = process.cwd(), sourceRoots = DEFAULT_SOURCE_ROOTS, locales = DEFAULT_LOCALES, pluralCategories } = {})=>{
|
|
104
107
|
const oxlintResult = runOxlintRules({
|
|
105
108
|
cwd,
|
|
106
109
|
targets: sourceRoots,
|
|
@@ -133,13 +136,83 @@ const runWorkspaceSourceCheck = ({ cwd = process.cwd(), sourceRoots = [
|
|
|
133
136
|
try {
|
|
134
137
|
runRuntimeAndLocaleResourceChecks(cwd, sourceRoots, locales, pluralCategories);
|
|
135
138
|
} catch (error) {
|
|
136
|
-
console.error(error instanceof Error ? error.message :
|
|
137
|
-
return 1;
|
|
139
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
140
|
+
return error instanceof SourceViolation ? 1 : 2;
|
|
138
141
|
}
|
|
139
142
|
console.log(WORKSPACE_SOURCE_SUCCESS);
|
|
140
143
|
return 0;
|
|
141
144
|
};
|
|
142
|
-
const
|
|
143
|
-
|
|
145
|
+
const USAGE = `modern-i18n-check [--workspace-root <path>]
|
|
146
|
+
Runs the UltraModern i18n and boundary source checks for a workspace.
|
|
147
|
+
Workspace defaults to ULTRAMODERN_WORKSPACE_ROOT then cwd. Options come from
|
|
148
|
+
package.json "modernjs.i18nCheck": { sourceRoots, locales, pluralCategories }.
|
|
149
|
+
Exit codes: 0 valid, 1 source violation, 2 tool/configuration failure.`;
|
|
150
|
+
const isStringArray = (value)=>Array.isArray(value) && value.every((entry)=>'string' == typeof entry && entry.length > 0);
|
|
151
|
+
const isLocaleArray = (value)=>{
|
|
152
|
+
if (!isStringArray(value)) return false;
|
|
153
|
+
try {
|
|
154
|
+
Intl.getCanonicalLocales(value);
|
|
155
|
+
return true;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
|
|
161
|
+
const isWorkspaceDirectory = (root, entry)=>{
|
|
162
|
+
if (node_path.isAbsolute(entry)) return false;
|
|
163
|
+
const target = node_path.resolve(root, entry);
|
|
164
|
+
if (!node_fs.existsSync(target) || !node_fs.statSync(target).isDirectory()) return false;
|
|
165
|
+
const relative = node_path.relative(node_fs.realpathSync(root), node_fs.realpathSync(target));
|
|
166
|
+
return '' !== relative && '..' !== relative && !relative.startsWith(`..${node_path.sep}`) && !node_path.isAbsolute(relative);
|
|
167
|
+
};
|
|
168
|
+
const CLDR_PLURAL_CATEGORIES = new Set([
|
|
169
|
+
'zero',
|
|
170
|
+
'one',
|
|
171
|
+
'two',
|
|
172
|
+
'few',
|
|
173
|
+
'many',
|
|
174
|
+
'other'
|
|
175
|
+
]);
|
|
176
|
+
const isPluralCategories = (value)=>isRecord(value) && Object.values(value).every((categories)=>isStringArray(categories) && categories.includes('other') && categories.every((category)=>CLDR_PLURAL_CATEGORIES.has(category)));
|
|
177
|
+
const readWorkspaceCheckOptions = (root)=>{
|
|
178
|
+
const manifestPath = node_path.join(root, 'package.json');
|
|
179
|
+
const modernjs = JSON.parse(readText(manifestPath)).modernjs;
|
|
180
|
+
const config = isRecord(modernjs) && Object.hasOwn(modernjs, 'i18nCheck') ? modernjs.i18nCheck : {};
|
|
181
|
+
const invalid = (field, expected)=>new Error(`${manifestPath} "modernjs.i18nCheck${field}" must be ${expected}.`);
|
|
182
|
+
if (!isRecord(config)) throw invalid('', 'an object');
|
|
183
|
+
const { locales, pluralCategories } = config;
|
|
184
|
+
if (void 0 !== locales && !isLocaleArray(locales)) throw invalid('.locales', 'an array of BCP 47 locale codes');
|
|
185
|
+
if (void 0 !== pluralCategories && !isPluralCategories(pluralCategories)) throw invalid('.pluralCategories', 'an object of locale -> CLDR plural categories including "other"');
|
|
186
|
+
const sourceRoots = Object.hasOwn(config, 'sourceRoots') ? config.sourceRoots : DEFAULT_SOURCE_ROOTS.filter((entry)=>isWorkspaceDirectory(root, entry));
|
|
187
|
+
if (!isStringArray(sourceRoots) || 0 === sourceRoots.length || !sourceRoots.every((entry)=>isWorkspaceDirectory(root, entry))) throw invalid('.sourceRoots', `a non-empty array of existing workspace-relative directories (default: whichever of ${DEFAULT_SOURCE_ROOTS.join(', ')} exist)`);
|
|
188
|
+
return {
|
|
189
|
+
cwd: root,
|
|
190
|
+
sourceRoots,
|
|
191
|
+
locales,
|
|
192
|
+
pluralCategories
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
const runWorkspaceSourceCheckCli = (args = process.argv.slice(2))=>{
|
|
196
|
+
let workspaceRoot = process.env.ULTRAMODERN_WORKSPACE_ROOT ?? process.cwd();
|
|
197
|
+
for(let index = 0; index < args.length; index += 1){
|
|
198
|
+
const argument = args[index];
|
|
199
|
+
if ('--help' === argument || '-h' === argument) {
|
|
200
|
+
console.log(USAGE);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
const value = args[index + 1];
|
|
204
|
+
if ('--workspace-root' !== argument || !value || value.startsWith('--')) {
|
|
205
|
+
console.error(`Invalid argument: ${argument ?? ''}. Use --help.`);
|
|
206
|
+
return 2;
|
|
207
|
+
}
|
|
208
|
+
workspaceRoot = value;
|
|
209
|
+
index += 1;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
return runWorkspaceSourceCheck(readWorkspaceCheckOptions(node_path.resolve(workspaceRoot)));
|
|
213
|
+
} catch (error) {
|
|
214
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
215
|
+
return 2;
|
|
216
|
+
}
|
|
144
217
|
};
|
|
145
|
-
export {
|
|
218
|
+
export { runWorkspaceSourceCheck, runWorkspaceSourceCheckCli };
|