@bleedingdev/modern-js-code-tools 3.9.0-ultramodern.9 → 3.9.3-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.
Files changed (38) hide show
  1. package/bin/modern-api-check-files.mjs +4 -1
  2. package/bin/modern-api-check.mjs +1 -1
  3. package/bin/modern-i18n-check.mjs +4 -0
  4. package/dist/cjs/cli/microvertical-api-check.cjs +36 -7
  5. package/dist/cjs/cli/workspace-source-check.cjs +90 -17
  6. package/dist/cjs/microvertical-api-baseline.cjs +89 -246
  7. package/dist/cjs/microvertical-api-boundary.cjs +507 -64
  8. package/dist/cjs/microvertical-api-owner.cjs +5 -2
  9. package/dist/cjs/module-graph.cjs +1862 -0
  10. package/dist/cjs/oxlint-plugin/rules/strict-effect-api-boundaries.cjs +1 -1
  11. package/dist/cjs/source-analysis.cjs +108 -0
  12. package/dist/cjs/strict-effect-runtime.cjs +75 -57
  13. package/dist/esm/cli/microvertical-api-check.js +26 -7
  14. package/dist/esm/cli/workspace-source-check.js +86 -13
  15. package/dist/esm/microvertical-api-baseline.js +66 -214
  16. package/dist/esm/microvertical-api-boundary.js +505 -65
  17. package/dist/esm/microvertical-api-owner.js +5 -2
  18. package/dist/esm/module-graph.js +1804 -0
  19. package/dist/esm/oxlint-plugin/rules/strict-effect-api-boundaries.js +1 -1
  20. package/dist/esm/source-analysis.js +48 -0
  21. package/dist/esm/strict-effect-runtime.js +75 -56
  22. package/dist/esm-node/cli/microvertical-api-check.js +26 -7
  23. package/dist/esm-node/cli/workspace-source-check.js +86 -13
  24. package/dist/esm-node/microvertical-api-baseline.js +66 -214
  25. package/dist/esm-node/microvertical-api-boundary.js +505 -65
  26. package/dist/esm-node/microvertical-api-owner.js +5 -2
  27. package/dist/esm-node/module-graph.js +1805 -0
  28. package/dist/esm-node/oxlint-plugin/rules/strict-effect-api-boundaries.js +1 -1
  29. package/dist/esm-node/source-analysis.js +49 -0
  30. package/dist/esm-node/strict-effect-runtime.js +75 -56
  31. package/dist/types/cli/microvertical-api-check.d.ts +1 -1
  32. package/dist/types/cli/workspace-source-check.d.ts +1 -1
  33. package/dist/types/microvertical-api-baseline.d.ts +4 -5
  34. package/dist/types/microvertical-api-boundary.d.ts +17 -2
  35. package/dist/types/microvertical-api-owner.d.ts +1 -1
  36. package/dist/types/module-graph.d.ts +100 -0
  37. package/dist/types/source-analysis.d.ts +13 -0
  38. package/package.json +17 -13
@@ -20,7 +20,7 @@ const createStrictEffectApiBoundariesRule = ()=>({
20
20
  });
21
21
  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
22
  reportProgramPattern(context, node, source, /\bruntimeFramework\s*(?::|=)\s*['"]hono['"]/u, 'UltraModern API apps must use bff.runtimeFramework: "effect".');
23
- 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.');
23
+ 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.');
24
24
  reportProgramPattern(context, node, source, /\bstrictEffectApproach\s*(?::|=)\s*false\b/u, 'UltraModern API apps must keep strictEffectApproach enabled.');
25
25
  if (isApiEntryFile(filename)) {
26
26
  const violation = strictEffectRuntimeTopologyViolation(source, createEffectApiImportResolver(filename));
@@ -0,0 +1,48 @@
1
+ import { parse } from "@babel/parser";
2
+ import traverse, { Hub, NodePath } from "@babel/traverse";
3
+ import * as __rspack_external__babel_types_6e8337dd from "@babel/types";
4
+ const consumerParserPlugins = (filePath)=>/\.[jt]sx$/u.test(filePath) ? [
5
+ "typescript",
6
+ 'decorators',
7
+ 'decoratorAutoAccessors',
8
+ 'jsx'
9
+ ] : [
10
+ "typescript",
11
+ 'decorators',
12
+ 'decoratorAutoAccessors'
13
+ ];
14
+ class SourceSyntaxError extends Error {
15
+ }
16
+ class SourceValidationHub extends Hub {
17
+ buildError(_node, message) {
18
+ return new SourceSyntaxError(message);
19
+ }
20
+ }
21
+ function parseSource(source, filename, syntaxMessage) {
22
+ try {
23
+ return parse(source, {
24
+ sourceType: 'module',
25
+ sourceFilename: filename,
26
+ plugins: consumerParserPlugins(filename)
27
+ });
28
+ } catch (cause) {
29
+ 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)));
30
+ throw cause;
31
+ }
32
+ }
33
+ function traverseSource(file, visitor) {
34
+ const program = NodePath.get({
35
+ hub: new SourceValidationHub(),
36
+ parentPath: void 0,
37
+ parent: file,
38
+ container: file,
39
+ key: 'program'
40
+ });
41
+ program.setContext();
42
+ traverse(file, visitor, program.scope, void 0, program);
43
+ }
44
+ function unwrapExpression(node, includeAssertions = false) {
45
+ while(__rspack_external__babel_types_6e8337dd.isTSAsExpression(node) || __rspack_external__babel_types_6e8337dd.isParenthesizedExpression(node) || __rspack_external__babel_types_6e8337dd.isTSSatisfiesExpression(node) || includeAssertions && (__rspack_external__babel_types_6e8337dd.isTSTypeAssertion(node) || __rspack_external__babel_types_6e8337dd.isTSNonNullExpression(node) || __rspack_external__babel_types_6e8337dd.isTSInstantiationExpression(node)))node = node.expression;
46
+ return node;
47
+ }
48
+ export { SourceSyntaxError, consumerParserPlugins, parseSource, traverseSource, unwrapExpression };
@@ -1,23 +1,25 @@
1
1
  import node_fs from "node:fs";
2
2
  import node_path from "node:path";
3
- import { parse } from "@babel/parser";
4
- import traverse, { Hub, NodePath } from "@babel/traverse";
3
+ import { SourceSyntaxError, parseSource, traverseSource, unwrapExpression } from "./source-analysis.js";
5
4
  import * as __rspack_external__babel_types_6e8337dd from "@babel/types";
6
5
  const edge = '@modern-js/bff-effect/effect-edge';
7
6
  const nodeRuntime = '@modern-js/bff-effect/effect';
8
- const sharedRuntime = /^@[^/]+\/shared-contracts\/server\/effect-bff-runtime$/u;
9
- const failure = 'Generated API entries must export defineEffectBff(...) or the server-only shared Effect BFF assembly helper with an explicitly composed handler Layer and an unshadowed executable root; entries must implement handlers through HttpApiBuilder.group.';
10
- const invalidSources = new WeakSet();
11
- function invalidSource(message) {
12
- const error = new Error(message);
13
- invalidSources.add(error);
14
- return error;
15
- }
16
- class SourceValidationHub extends Hub {
17
- buildError(_node, message) {
18
- return invalidSource(message);
19
- }
20
- }
7
+ const assembly = '@modern-js/bff-effect/assembly';
8
+ 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.';
9
+ const MAX_API_SOURCE_MODULES = 256;
10
+ const httpRouteMethods = new Set([
11
+ '*',
12
+ 'GET',
13
+ 'POST',
14
+ 'PUT',
15
+ 'PATCH',
16
+ 'DELETE',
17
+ 'HEAD',
18
+ 'OPTIONS',
19
+ 'TRACE',
20
+ 'CONNECT',
21
+ 'QUERY'
22
+ ]);
21
23
  function createEffectApiImportResolver(filename) {
22
24
  const directory = node_fs.realpathSync(node_path.dirname(filename));
23
25
  const owner = node_fs.realpathSync(node_path.resolve(directory, '..'));
@@ -49,34 +51,14 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
49
51
  const load = (input)=>{
50
52
  const existing = modules.get(input.id);
51
53
  if (existing) return existing;
52
- if (modules.size >= 64 || input.source.length > 1000000) throw invalidSource('API source budget exceeded');
53
- let file;
54
- try {
55
- file = parse(input.source, {
56
- sourceType: 'module',
57
- sourceFilename: input.id,
58
- plugins: [
59
- "typescript"
60
- ]
61
- });
62
- } catch (cause) {
63
- 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');
64
- throw cause;
65
- }
54
+ if (modules.size >= MAX_API_SOURCE_MODULES || input.source.length > 1000000) throw new SourceSyntaxError('API source budget exceeded');
55
+ const file = parseSource(input.source, input.id, 'Invalid API syntax');
66
56
  const module = {
67
57
  ...input,
68
58
  file
69
59
  };
70
60
  modules.set(input.id, module);
71
- const program = NodePath.get({
72
- hub: new SourceValidationHub(),
73
- parentPath: void 0,
74
- parent: file,
75
- container: file,
76
- key: 'program'
77
- });
78
- program.setContext();
79
- traverse(file, {
61
+ traverseSource(file, {
80
62
  enter (nodePath) {
81
63
  paths.set(nodePath.node, nodePath);
82
64
  owners.set(nodePath.node, module);
@@ -85,9 +67,12 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
85
67
  })) {
86
68
  let target = nodePath.isAssignmentExpression() ? nodePath.node.left : nodePath.node.argument;
87
69
  while(__rspack_external__babel_types_6e8337dd.isMemberExpression(target))target = target.object;
88
- if (__rspack_external__babel_types_6e8337dd.isIdentifier(target)) {
89
- const binding = nodePath.scope.getBinding(target.name);
90
- if (binding) mutated.add(binding);
70
+ let binding = __rspack_external__babel_types_6e8337dd.isIdentifier(target) ? nodePath.scope.getBinding(target.name) : void 0;
71
+ while(binding && !mutated.has(binding)){
72
+ mutated.add(binding);
73
+ const declarator = binding.path.node;
74
+ const alias = 'const' === binding.kind && __rspack_external__babel_types_6e8337dd.isVariableDeclarator(declarator) && declarator.init ? unwrapExpression(declarator.init, true) : void 0;
75
+ binding = __rspack_external__babel_types_6e8337dd.isIdentifier(alias) ? binding.path.scope.getBinding(alias.name) : void 0;
91
76
  }
92
77
  }
93
78
  if (nodePath.isTSModuleDeclaration() && __rspack_external__babel_types_6e8337dd.isIdentifier(nodePath.node.id)) {
@@ -95,7 +80,7 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
95
80
  if (binding) mutated.add(binding);
96
81
  }
97
82
  }
98
- }, program.scope, void 0, program);
83
+ });
99
84
  return module;
100
85
  };
101
86
  try {
@@ -104,10 +89,7 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
104
89
  source,
105
90
  resolveImport
106
91
  });
107
- const unwrap = (node)=>{
108
- while(__rspack_external__babel_types_6e8337dd.isParenthesizedExpression(node) || __rspack_external__babel_types_6e8337dd.isTSSatisfiesExpression(node) || __rspack_external__babel_types_6e8337dd.isTSAsExpression(node) || __rspack_external__babel_types_6e8337dd.isTSTypeAssertion(node) || __rspack_external__babel_types_6e8337dd.isTSNonNullExpression(node))node = node.expression;
109
- return node;
110
- };
92
+ const unwrap = (node)=>unwrapExpression(node, true);
111
93
  const binding = (node)=>{
112
94
  node = unwrap(node);
113
95
  if (!__rspack_external__babel_types_6e8337dd.isIdentifier(node)) return;
@@ -132,15 +114,15 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
132
114
  const namespaceSources = {
133
115
  HttpApi: [
134
116
  edge,
135
- 'effect/unstable/httpapi'
117
+ 'effect/http-api'
136
118
  ],
137
119
  HttpApiBuilder: [
138
120
  edge,
139
- 'effect/unstable/httpapi'
121
+ 'effect/http-api'
140
122
  ],
141
123
  HttpRouter: [
142
124
  edge,
143
- 'effect/unstable/http'
125
+ 'effect/http'
144
126
  ]
145
127
  };
146
128
  const native = (node, name, sources)=>{
@@ -236,7 +218,7 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
236
218
  if (!__rspack_external__babel_types_6e8337dd.isBlockStatement(body)) return body;
237
219
  const statements = body.body;
238
220
  const last = statements.at(-1);
239
- return statements.slice(0, -1).every((statement)=>__rspack_external__babel_types_6e8337dd.isVariableDeclaration(statement)) && __rspack_external__babel_types_6e8337dd.isReturnStatement(last) ? last.argument : void 0;
221
+ return statements.slice(0, -1).every((statement)=>__rspack_external__babel_types_6e8337dd.isVariableDeclaration(statement) || __rspack_external__babel_types_6e8337dd.isTSTypeAliasDeclaration(statement) || __rspack_external__babel_types_6e8337dd.isTSInterfaceDeclaration(statement)) && __rspack_external__babel_types_6e8337dd.isReturnStatement(last) ? last.argument : void 0;
240
222
  };
241
223
  const pipe = (node, check)=>__rspack_external__babel_types_6e8337dd.isMemberExpression(node.callee) && !node.callee.computed && __rspack_external__babel_types_6e8337dd.isIdentifier(node.callee.property, {
242
224
  name: 'pipe'
@@ -265,11 +247,48 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
265
247
  if (method(node.callee, 'Layer', 'mergeAll', layerSources)) return node.arguments.length > 0 && node.arguments.every((argument)=>handlers(argument, api));
266
248
  return method(node.callee, 'HttpApiBuilder', 'group') && 3 === node.arguments.length && apiIdentity(node.arguments[0]) === api && handled(node.arguments[2]);
267
249
  });
268
- const transport = (node)=>guarded(node, (node)=>{
250
+ const effectSources = [
251
+ edge,
252
+ 'effect',
253
+ 'effect/Effect'
254
+ ];
255
+ const routeHandler = (node)=>{
256
+ node = unwrap(node);
257
+ if (!__rspack_external__babel_types_6e8337dd.isCallExpression(node) || !method(node.callee, 'HttpRouter', 'add') || 3 !== node.arguments.length) return;
258
+ const [verb, route, handler] = node.arguments;
259
+ return __rspack_external__babel_types_6e8337dd.isStringLiteral(verb) && httpRouteMethods.has(verb.value) && __rspack_external__babel_types_6e8337dd.isStringLiteral(route) && (route.value.startsWith('/') || '*' === route.value) && (__rspack_external__babel_types_6e8337dd.isArrowFunctionExpression(handler) || __rspack_external__babel_types_6e8337dd.isFunctionExpression(handler)) ? handler : void 0;
260
+ };
261
+ const effectReference = (node)=>guarded(node, (node)=>{
262
+ const init = initialized(node);
263
+ if (init) return __rspack_external__babel_types_6e8337dd.isIdentifier(unwrap(init)) && effectReference(init);
264
+ const value = imported(node);
265
+ return !!value && !value.namespace;
266
+ });
267
+ const capturedRouteFactory = (node)=>guarded(node, (node)=>{
268
+ const init = initialized(node) ?? externalValue(node);
269
+ if (init) return capturedRouteFactory(init);
270
+ if (!__rspack_external__babel_types_6e8337dd.isArrowFunctionExpression(node) || node.async || 1 !== node.params.length || !__rspack_external__babel_types_6e8337dd.isIdentifier(node.params[0]) || __rspack_external__babel_types_6e8337dd.isBlockStatement(node.body)) return false;
271
+ const handler = routeHandler(node.body);
272
+ const parameter = binding(node.params[0]);
273
+ return !!handler && !!parameter && parameter.referencePaths.some((reference)=>!!reference.findParent((path)=>path.node === handler));
274
+ });
275
+ const capturedTransport = (node)=>guarded(node, (node)=>{
269
276
  const init = initialized(node);
270
- if (init) return transport(init);
277
+ if (init) return capturedTransport(init);
278
+ if (!__rspack_external__babel_types_6e8337dd.isCallExpression(node)) return false;
279
+ if (method(node.callee, 'Effect', 'map', effectSources)) return 2 === node.arguments.length && effectReference(node.arguments[0]) && capturedRouteFactory(node.arguments[1]);
280
+ if (!__rspack_external__babel_types_6e8337dd.isMemberExpression(node.callee) || node.callee.computed || !__rspack_external__babel_types_6e8337dd.isIdentifier(node.callee.property, {
281
+ name: 'pipe'
282
+ }) || 1 !== node.arguments.length || !effectReference(node.callee.object)) return false;
283
+ const map = unwrap(node.arguments[0]);
284
+ return __rspack_external__babel_types_6e8337dd.isCallExpression(map) && method(map.callee, 'Effect', 'map', effectSources) && 1 === map.arguments.length && capturedRouteFactory(map.arguments[0]);
285
+ });
286
+ const transport = (node, provided = false)=>guarded(node, (node)=>{
287
+ const init = initialized(node) ?? externalValue(node);
288
+ if (init) return transport(init, provided);
271
289
  if (!__rspack_external__babel_types_6e8337dd.isCallExpression(node)) return method(node, 'Layer', 'empty', layerSources);
272
- return pipe(node, transport) || method(node.callee, 'HttpRouter', 'cors') || method(node.callee, 'Layer', 'mergeAll', layerSources) && node.arguments.length > 0 && node.arguments.every(transport);
290
+ const providedHere = provided || node.arguments.some((argument)=>__rspack_external__babel_types_6e8337dd.isCallExpression(argument) && method(argument.callee, 'Layer', 'provide', layerSources) && 1 === argument.arguments.length);
291
+ return pipe(node, (value)=>transport(value, providedHere)) || void 0 !== routeHandler(node) || provided && method(node.callee, 'Layer', 'unwrap', layerSources) && 1 === node.arguments.length && capturedTransport(node.arguments[0]) || method(node.callee, 'HttpRouter', 'cors') || method(node.callee, 'Layer', 'mergeAll', layerSources) && node.arguments.length > 0 && node.arguments.every((argument)=>transport(argument, provided));
273
292
  });
274
293
  const directLayer = (node, api)=>guarded(node, (node)=>{
275
294
  const init = initialized(node);
@@ -301,7 +320,7 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
301
320
  const groupValue = externalValue(group);
302
321
  const groupInit = groupValue && initialized(groupValue);
303
322
  if (!groupInit || !__rspack_external__babel_types_6e8337dd.isCallExpression(groupInit) || !method(groupInit.callee, 'RpcGroup', 'make', [
304
- 'effect/unstable/rpc',
323
+ 'effect/rpc',
305
324
  '@modern-js/bff-effect/effect-client'
306
325
  ])) return false;
307
326
  const layerInit = initialized(layer) ?? unwrap(layer);
@@ -325,7 +344,7 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
325
344
  if (value?.name === 'defineEffectBff' && [
326
345
  edge,
327
346
  nodeRuntime
328
- ].includes(value.specifier) || value?.name === 'assembleEffectBffRuntime' && sharedRuntime.test(value.specifier)) {
347
+ ].includes(value.specifier) || value?.name === 'assembleEffectBffRuntime' && value.specifier === assembly) {
329
348
  if (1 !== node.arguments.length) return false;
330
349
  const values = properties(node.arguments[0]);
331
350
  if (!values) return false;
@@ -348,7 +367,7 @@ function strictEffectRuntimeTopologyViolation(source, resolveImport) {
348
367
  const root = entry.file.program.body.find((statement)=>__rspack_external__babel_types_6e8337dd.isExportDefaultDeclaration(statement));
349
368
  return root && runtime(root.declaration) ? void 0 : failure;
350
369
  } catch (cause) {
351
- if ('object' == typeof cause && null !== cause && invalidSources.has(cause)) return failure;
370
+ if (cause instanceof SourceSyntaxError) return failure;
352
371
  const message = 'object' == typeof cause && null !== cause && 'message' in cause && 'string' == typeof cause.message ? cause.message : String(cause);
353
372
  throw new Error(`Strict Effect API analysis failed: ${message}`, {
354
373
  cause
@@ -1,28 +1,47 @@
1
1
  import "node:module";
2
+ import node_path from "node:path";
3
+ import { pathToFileURL } from "node:url";
2
4
  import { checkMicroVerticalApiBoundaries, checkMicroVerticalApiConsumerFiles } from "../microvertical-api-boundary.js";
3
- function runMicroVerticalApiCheckCli(args = process.argv.slice(2), filesOnly = false) {
5
+ const flags = [
6
+ '--workspace-root',
7
+ '--baseline-package-directory',
8
+ '--rules'
9
+ ];
10
+ async function loadSourceRules(file) {
11
+ const rules = (await import(pathToFileURL(node_path.resolve(file)).href)).default;
12
+ 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]\``);
13
+ return rules;
14
+ }
15
+ async function runMicroVerticalApiCheckCli(args = process.argv.slice(2), filesOnly = false) {
4
16
  let workspaceRoot = process.env.ULTRAMODERN_WORKSPACE_ROOT ?? process.cwd();
5
17
  let baselinePackageDirectory;
18
+ const ruleFiles = [];
6
19
  for(let index = 0; index < args.length; index += 1){
7
20
  const argument = args[index];
8
21
  if ('--help' === argument || '-h' === argument) {
9
- console.log(`${filesOnly ? 'modern-api-check-files' : 'modern-api-check'} [--workspace-root <path>] [--baseline-package-directory <path>]\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.'}\nWorkspace defaults to ULTRAMODERN_WORKSPACE_ROOT then cwd. Exit codes: 0 valid, 1 consumer violation, 2 tool/configuration failure.`);
22
+ 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.`);
10
23
  return 0;
11
24
  }
12
- if (![
13
- '--workspace-root',
14
- '--baseline-package-directory'
15
- ].includes(argument ?? '') || !args[index + 1] || args[index + 1]?.startsWith('--')) {
25
+ if (!flags.includes(argument ?? '') || !args[index + 1] || args[index + 1]?.startsWith('--')) {
16
26
  console.error(`Invalid argument: ${argument ?? ''}. Use --help.`);
17
27
  return 2;
18
28
  }
19
29
  const value = args[++index];
20
30
  if ('--workspace-root' === argument) workspaceRoot = value;
31
+ else if ('--rules' === argument) ruleFiles.push(value);
21
32
  else baselinePackageDirectory = value;
22
33
  }
34
+ const sourceRules = [];
35
+ for (const file of ruleFiles)try {
36
+ sourceRules.push(...await loadSourceRules(file));
37
+ } catch (error) {
38
+ console.error(`API tool error: --rules ${error instanceof Error ? error.message : String(error)}`);
39
+ return 2;
40
+ }
23
41
  const result = (filesOnly ? checkMicroVerticalApiConsumerFiles : checkMicroVerticalApiBoundaries)({
24
42
  workspaceRoot,
25
- baselinePackageDirectory
43
+ baselinePackageDirectory,
44
+ sourceRules
26
45
  });
27
46
  for (const diagnostic of result.diagnostics)console.error(`API violation: ${diagnostic}`);
28
47
  for (const error of result.toolErrors)console.error(`API tool error: ${error}`);
@@ -7,6 +7,11 @@ const DEFAULT_LOCALES = [
7
7
  'en',
8
8
  'cs'
9
9
  ];
10
+ const DEFAULT_SOURCE_ROOTS = [
11
+ 'apps',
12
+ 'verticals',
13
+ 'packages'
14
+ ];
10
15
  const ignoredDirectories = new Set([
11
16
  '.modern',
12
17
  '.modernjs',
@@ -40,6 +45,8 @@ const createLocaleJsonMatcher = (locales)=>{
40
45
  const resolvePluralCategories = (locale, overrides)=>overrides?.[locale] ?? new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
41
46
  const readText = (filePath)=>node_fs.readFileSync(filePath, 'utf-8');
42
47
  const localeImportPattern = (locale)=>new RegExp(`import\\s+(?:\\*\\s+as\\s+)?[A-Za-z_$][\\w$]*\\s+from\\s+['"]\\.\\./locales/${escapeRegExp(locale)}/[^'"]+\\.json['"]`, 'u');
48
+ class SourceViolation extends Error {
49
+ }
43
50
  const checkRuntimeResources = (root, filePath, text, locales)=>{
44
51
  const relative = relativePath(root, filePath);
45
52
  if (!relative.endsWith('/src/modern.runtime.ts')) return;
@@ -47,7 +54,7 @@ const checkRuntimeResources = (root, filePath, text, locales)=>{
47
54
  const registersResources = /initOptions\s*:\s*\{[\s\S]*?\bresources\s*[,:}]/u.test(text);
48
55
  if (missingLocales.length > 0 || !registersResources) {
49
56
  const detail = missingLocales.length > 0 ? `missing locale JSON imports for: ${missingLocales.join(', ')}` : 'initOptions does not register a `resources` entry';
50
- throw new Error(`${relative} must register locale JSON resources in modern.runtime.ts so Worker SSR and hydration use the same first-render translations (${detail}).`);
57
+ 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}).`);
51
58
  }
52
59
  };
53
60
  const visitLocaleKeys = (value, visitor, pathParts = [])=>{
@@ -67,7 +74,7 @@ const checkPluralResources = (root, filePath, json, requiredSuffixes, pluralSuff
67
74
  visitLocaleKeys(json, (key, value, pathParts)=>{
68
75
  if ('string' != typeof value || !value.includes('{{count}}')) return;
69
76
  const suffixMatch = key.match(pluralSuffixPattern);
70
- if (!suffixMatch) throw new Error(`${relative} key ${pathParts.join('.')} contains {{count}} but is not plural-suffixed.`);
77
+ if (!suffixMatch) throw new SourceViolation(`${relative} key ${pathParts.join('.')} contains {{count}} but is not plural-suffixed.`);
71
78
  const [, base = '', suffix = ''] = suffixMatch;
72
79
  const parentPath = pathParts.slice(0, -1).join('.');
73
80
  const groupKey = `${parentPath}.${base}`;
@@ -75,7 +82,7 @@ const checkPluralResources = (root, filePath, json, requiredSuffixes, pluralSuff
75
82
  existing.add(suffix);
76
83
  groups.set(groupKey, existing);
77
84
  });
78
- for (const [group, suffixes] of groups)for (const suffix of requiredSuffixes)if (!suffixes.has(suffix)) throw new Error(`${relative} plural group ${group} is missing _${suffix}.`);
85
+ 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}.`);
79
86
  };
80
87
  const runRuntimeAndLocaleResourceChecks = (root, sourceRoots, locales, pluralCategories)=>{
81
88
  if (0 === locales.length) return;
@@ -97,11 +104,7 @@ const runRuntimeAndLocaleResourceChecks = (root, sourceRoots, locales, pluralCat
97
104
  checkPluralResources(root, filePath, JSON.parse(readText(filePath)), localeCategories.get(language) ?? [], pluralSuffixPattern);
98
105
  }
99
106
  };
100
- const runWorkspaceSourceCheck = ({ cwd = process.cwd(), sourceRoots = [
101
- 'apps',
102
- 'verticals',
103
- 'packages'
104
- ], locales = DEFAULT_LOCALES, pluralCategories } = {})=>{
107
+ const runWorkspaceSourceCheck = ({ cwd = process.cwd(), sourceRoots = DEFAULT_SOURCE_ROOTS, locales = DEFAULT_LOCALES, pluralCategories } = {})=>{
105
108
  const oxlintResult = runOxlintRules({
106
109
  cwd,
107
110
  targets: sourceRoots,
@@ -134,13 +137,83 @@ const runWorkspaceSourceCheck = ({ cwd = process.cwd(), sourceRoots = [
134
137
  try {
135
138
  runRuntimeAndLocaleResourceChecks(cwd, sourceRoots, locales, pluralCategories);
136
139
  } catch (error) {
137
- console.error(error instanceof Error ? error.message : 'UltraModern workspace source checks failed.');
138
- return 1;
140
+ console.error(error instanceof Error ? error.message : String(error));
141
+ return error instanceof SourceViolation ? 1 : 2;
139
142
  }
140
143
  console.log(WORKSPACE_SOURCE_SUCCESS);
141
144
  return 0;
142
145
  };
143
- const main = ()=>{
144
- process.exitCode = runWorkspaceSourceCheck();
146
+ const USAGE = `modern-i18n-check [--workspace-root <path>]
147
+ Runs the UltraModern i18n and boundary source checks for a workspace.
148
+ Workspace defaults to ULTRAMODERN_WORKSPACE_ROOT then cwd. Options come from
149
+ package.json "modernjs.i18nCheck": { sourceRoots, locales, pluralCategories }.
150
+ Exit codes: 0 valid, 1 source violation, 2 tool/configuration failure.`;
151
+ const isStringArray = (value)=>Array.isArray(value) && value.every((entry)=>'string' == typeof entry && entry.length > 0);
152
+ const isLocaleArray = (value)=>{
153
+ if (!isStringArray(value)) return false;
154
+ try {
155
+ Intl.getCanonicalLocales(value);
156
+ return true;
157
+ } catch {
158
+ return false;
159
+ }
160
+ };
161
+ const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
162
+ const isWorkspaceDirectory = (root, entry)=>{
163
+ if (node_path.isAbsolute(entry)) return false;
164
+ const target = node_path.resolve(root, entry);
165
+ if (!node_fs.existsSync(target) || !node_fs.statSync(target).isDirectory()) return false;
166
+ const relative = node_path.relative(node_fs.realpathSync(root), node_fs.realpathSync(target));
167
+ return '' !== relative && '..' !== relative && !relative.startsWith(`..${node_path.sep}`) && !node_path.isAbsolute(relative);
168
+ };
169
+ const CLDR_PLURAL_CATEGORIES = new Set([
170
+ 'zero',
171
+ 'one',
172
+ 'two',
173
+ 'few',
174
+ 'many',
175
+ 'other'
176
+ ]);
177
+ const isPluralCategories = (value)=>isRecord(value) && Object.values(value).every((categories)=>isStringArray(categories) && categories.includes('other') && categories.every((category)=>CLDR_PLURAL_CATEGORIES.has(category)));
178
+ const readWorkspaceCheckOptions = (root)=>{
179
+ const manifestPath = node_path.join(root, 'package.json');
180
+ const modernjs = JSON.parse(readText(manifestPath)).modernjs;
181
+ const config = isRecord(modernjs) && Object.hasOwn(modernjs, 'i18nCheck') ? modernjs.i18nCheck : {};
182
+ const invalid = (field, expected)=>new Error(`${manifestPath} "modernjs.i18nCheck${field}" must be ${expected}.`);
183
+ if (!isRecord(config)) throw invalid('', 'an object');
184
+ const { locales, pluralCategories } = config;
185
+ if (void 0 !== locales && !isLocaleArray(locales)) throw invalid('.locales', 'an array of BCP 47 locale codes');
186
+ if (void 0 !== pluralCategories && !isPluralCategories(pluralCategories)) throw invalid('.pluralCategories', 'an object of locale -> CLDR plural categories including "other"');
187
+ const sourceRoots = Object.hasOwn(config, 'sourceRoots') ? config.sourceRoots : DEFAULT_SOURCE_ROOTS.filter((entry)=>isWorkspaceDirectory(root, entry));
188
+ 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)`);
189
+ return {
190
+ cwd: root,
191
+ sourceRoots,
192
+ locales,
193
+ pluralCategories
194
+ };
195
+ };
196
+ const runWorkspaceSourceCheckCli = (args = process.argv.slice(2))=>{
197
+ let workspaceRoot = process.env.ULTRAMODERN_WORKSPACE_ROOT ?? process.cwd();
198
+ for(let index = 0; index < args.length; index += 1){
199
+ const argument = args[index];
200
+ if ('--help' === argument || '-h' === argument) {
201
+ console.log(USAGE);
202
+ return 0;
203
+ }
204
+ const value = args[index + 1];
205
+ if ('--workspace-root' !== argument || !value || value.startsWith('--')) {
206
+ console.error(`Invalid argument: ${argument ?? ''}. Use --help.`);
207
+ return 2;
208
+ }
209
+ workspaceRoot = value;
210
+ index += 1;
211
+ }
212
+ try {
213
+ return runWorkspaceSourceCheck(readWorkspaceCheckOptions(node_path.resolve(workspaceRoot)));
214
+ } catch (error) {
215
+ console.error(error instanceof Error ? error.message : String(error));
216
+ return 2;
217
+ }
145
218
  };
146
- export { main, runWorkspaceSourceCheck };
219
+ export { runWorkspaceSourceCheck, runWorkspaceSourceCheckCli };