@stackonward/cli 1.4.0 → 1.5.0

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 (55) hide show
  1. package/README.md +248 -0
  2. package/dist/index.js +14093 -603
  3. package/dist/index.js.map +1 -1
  4. package/dist/resources/deploy/ci/github/caller.yml +38 -0
  5. package/dist/resources/deploy/ci/onedev/import.yml +14 -0
  6. package/dist/resources/deploy/nginx/cache.conf +17 -0
  7. package/dist/resources/deploy/nginx/compression.conf +23 -0
  8. package/dist/resources/deploy/nginx/proxy.conf +15 -0
  9. package/dist/resources/deploy/profiles/go-service/compose.yaml +19 -0
  10. package/dist/resources/deploy/profiles/nuxt-node/Dockerfile +17 -0
  11. package/dist/resources/deploy/profiles/static-files/nginx/site.conf.template +55 -0
  12. package/dist/resources/deploy/profiles/static-nginx/Dockerfile +18 -0
  13. package/dist/resources/deploy/profiles/static-nginx/nginx/default.conf.template +28 -0
  14. package/dist/resources/deploy/targets/compose-host/deploy.config.example.json +114 -0
  15. package/dist/resources/deploy/traffic/nginx/stackonward-nginx-traffic +96 -0
  16. package/dist/resources/registry.json +30 -30
  17. package/dist/resources/templates/base/.claude/docs/architecture.md +4 -0
  18. package/dist/resources/templates/base/AGENTS.md +15 -0
  19. package/dist/resources/templates/base/CLAUDE.md +15 -0
  20. package/dist/resources/templates/go-backend/.dockerignore +25 -3
  21. package/dist/resources/templates/go-backend/deploy.config.json +26 -0
  22. package/dist/resources/templates/go-backend/docs/runtime-provenance.json +9 -6
  23. package/dist/resources/templates/go-backend/docs/source-provenance.json +33 -22
  24. package/dist/resources/templates/go-backend/internal/common/enums/register_locale.go +7 -0
  25. package/dist/resources/templates/go-backend/internal/common/enums/register_locale_test.go +30 -0
  26. package/dist/resources/templates/go-backend/internal/common/locale/locale.go +102 -76
  27. package/dist/resources/templates/go-backend/internal/common/locale/locale_test.go +168 -45
  28. package/dist/resources/templates/go-backend/internal/core/commandreplay/model.go +5 -5
  29. package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence.go +20 -7
  30. package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence_test.go +51 -22
  31. package/dist/resources/templates/go-backend/internal/core/commandreplay/repository.go +2 -2
  32. package/dist/resources/templates/go-backend/internal/core/commandreplay/service.go +13 -24
  33. package/dist/resources/templates/go-backend/internal/core/commandreplay/service_persistence_test.go +98 -13
  34. package/dist/resources/templates/go-backend/internal/core/commandreplay/service_test.go +61 -36
  35. package/dist/resources/templates/go-backend/internal/core/commandreplay/types.go +6 -6
  36. package/dist/resources/templates/go-backend/internal/core/integrationevent/config.go +24 -0
  37. package/dist/resources/templates/go-backend/internal/core/integrationevent/errors.go +53 -0
  38. package/dist/resources/templates/go-backend/internal/core/integrationevent/model.go +71 -0
  39. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence.go +193 -0
  40. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_postgres_test.go +178 -0
  41. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_test.go +168 -0
  42. package/dist/resources/templates/go-backend/internal/core/integrationevent/provider.go +16 -0
  43. package/dist/resources/templates/go-backend/internal/core/integrationevent/registry.go +69 -0
  44. package/dist/resources/templates/go-backend/internal/core/integrationevent/repository.go +17 -0
  45. package/dist/resources/templates/go-backend/internal/core/integrationevent/service.go +283 -0
  46. package/dist/resources/templates/go-backend/internal/core/integrationevent/service_test.go +433 -0
  47. package/dist/resources/templates/go-backend/internal/core/integrationevent/types.go +67 -0
  48. package/dist/resources/templates/go-backend/migrations/000001_infrastructure.down.sql +6 -0
  49. package/dist/resources/templates/go-backend/migrations/000001_infrastructure.up.sql +176 -16
  50. package/dist/resources/templates/go-backend/tests/infrastructure.sql +109 -6
  51. package/dist/resources/templates/nuxt/.claude/docs/stack.md +5 -1
  52. package/dist/resources/templates/nuxt/.dockerignore +19 -0
  53. package/dist/resources/templates/nuxt/deploy.config.json +27 -0
  54. package/dist/resources/templates/nuxt/package.json +8 -8
  55. package/package.json +5 -3
@@ -52,6 +52,10 @@ Use actual paths in [Stack](stack.md). Nuxt routes belong in `app/pages/`, serve
52
52
 
53
53
  Request/response types stay beside the service/API owner. Component props/emits stay with the component. A type moves to a shared owner only when callers share semantics, not merely field names.
54
54
 
55
+ Directories, modules and packages carry owner context; child files and symbols keep only the minimum responsibility. Within an owner use short names such as `Header`, `Config`, `Status`, `Menu`, `EventInput` and `items`; across owners add only the shortest qualifier, such as `SiteHeader`, `LocaleMenu`, `GTMOptions` or `GA4Event`. Never exchange one redundant prefix for another: `MarketingHeader` -> `PublicHeader` remains forbidden.
56
+
57
+ Directories, packages and ordinary TypeScript modules use kebab-case; Vue composables use `useX.ts`; Vue component files and types use PascalCase; variables/functions use camelCase; only fixed invariants use `UPPER_SNAKE_CASE`. Framework routes and endpoints keep framework syntax. Generic transforms use business-free responsibility names. External identifiers such as `purchase`, `login` and `gateway_*` remain unchanged. Vague standalone or catch-all names such as `helper`, `utils`, `common` and `manager` are forbidden; choose a precise owner and responsibility. `temp`, `new` and `final` naming residue is forbidden. Search owners, siblings and usages before naming or renaming; update all references and delete superseded names together.
58
+
55
59
  | Type | Owner |
56
60
  | -------------------------- | --------------------------------------------- |
57
61
  | HTTP request/response | API or server contract |
@@ -67,6 +67,21 @@ For non-trivial additions, declare `PLACEMENT/NAMING` (owner, name, kind, respon
67
67
 
68
68
  External calls, user-selectable alternatives, variable business policy, replaceable technology, standard extension points and multi-tenant behavior require `EXTENSIBILITY-INTENT`: signal, current and expected implementations, confidence, pattern, interface/configuration first, and exact files a second adapter would change. Otherwise state `NO-ABSTRACTION-JUSTIFICATION` and its refactor trigger.
69
69
 
70
+ ## Owner-Scoped Naming (CRITICAL)
71
+
72
+ Directories, modules and packages are namespaces. Child files and symbols **MUST** keep only the minimum responsibility-bearing name and **MUST NOT** repeat the owner or replace one redundant caller prefix with another (`MarketingHeader` -> `PublicHeader` remains forbidden).
73
+
74
+ - Inside an explicit owner, use short names such as `Header`, `Config`, `Status`, `Menu`, `EventInput` or `items`; across owners, add only the shortest required qualifier, such as `SiteHeader`, `LocaleMenu`, `GTMOptions` or `GA4Event`.
75
+ - Names describe responsibility, never the first caller, page, brand, task or conversation. Pure transforms use business-free names; business and protocol nouns remain only when they enforce that contract.
76
+ - Directories, packages and ordinary TypeScript modules use kebab-case; Vue composables use `useX.ts`; Vue component files and types use PascalCase; variables and functions use camelCase; only true fixed invariants use UPPER_SNAKE_CASE. Framework route and endpoint files follow framework syntax.
77
+ - External or business-owned identifiers such as `purchase`, `login` and `gateway_*` are semantic contracts, not code-name redundancy, and remain unchanged.
78
+ - Vague standalone or catch-all names such as `helper`, `utils`, `common` and `manager` are forbidden; choose a precise owner and responsibility. `temp`, `new` and `final` naming residue is forbidden. Search owners, siblings and usages before naming or renaming; update all references and delete superseded names together.
79
+ - Nuxt component directories remain namespaces: preserve default path-aware auto-naming and never set global `pathPrefix: false`. Local explicit imports may use the short name; cross-owner and global auto-imports use the path qualifier.
80
+
81
+ ✅ `analytics/events.ts::EventInput`, `components/site/Header.vue` -> `SiteHeader`, `useTracking`, `GTMOptions`, `GA4Event`
82
+
83
+ ❌ `types/marketing-analytics.ts`, `MarketingHeader`, `PublicHeader`, `GoogleTagManagerOptions`
84
+
70
85
  ---
71
86
 
72
87
  # AI Execution Standard
@@ -67,6 +67,21 @@ For non-trivial additions, declare `PLACEMENT/NAMING` (owner, name, kind, respon
67
67
 
68
68
  External calls, user-selectable alternatives, variable business policy, replaceable technology, standard extension points and multi-tenant behavior require `EXTENSIBILITY-INTENT`: signal, current and expected implementations, confidence, pattern, interface/configuration first, and exact files a second adapter would change. Otherwise state `NO-ABSTRACTION-JUSTIFICATION` and its refactor trigger.
69
69
 
70
+ ## Owner-Scoped Naming (CRITICAL)
71
+
72
+ Directories, modules and packages are namespaces. Child files and symbols **MUST** keep only the minimum responsibility-bearing name and **MUST NOT** repeat the owner or replace one redundant caller prefix with another (`MarketingHeader` -> `PublicHeader` remains forbidden).
73
+
74
+ - Inside an explicit owner, use short names such as `Header`, `Config`, `Status`, `Menu`, `EventInput` or `items`; across owners, add only the shortest required qualifier, such as `SiteHeader`, `LocaleMenu`, `GTMOptions` or `GA4Event`.
75
+ - Names describe responsibility, never the first caller, page, brand, task or conversation. Pure transforms use business-free names; business and protocol nouns remain only when they enforce that contract.
76
+ - Directories, packages and ordinary TypeScript modules use kebab-case; Vue composables use `useX.ts`; Vue component files and types use PascalCase; variables and functions use camelCase; only true fixed invariants use UPPER_SNAKE_CASE. Framework route and endpoint files follow framework syntax.
77
+ - External or business-owned identifiers such as `purchase`, `login` and `gateway_*` are semantic contracts, not code-name redundancy, and remain unchanged.
78
+ - Vague standalone or catch-all names such as `helper`, `utils`, `common` and `manager` are forbidden; choose a precise owner and responsibility. `temp`, `new` and `final` naming residue is forbidden. Search owners, siblings and usages before naming or renaming; update all references and delete superseded names together.
79
+ - Nuxt component directories remain namespaces: preserve default path-aware auto-naming and never set global `pathPrefix: false`. Local explicit imports may use the short name; cross-owner and global auto-imports use the path qualifier.
80
+
81
+ ✅ `analytics/events.ts::EventInput`, `components/site/Header.vue` -> `SiteHeader`, `useTracking`, `GTMOptions`, `GA4Event`
82
+
83
+ ❌ `types/marketing-analytics.ts`, `MarketingHeader`, `PublicHeader`, `GoogleTagManagerOptions`
84
+
70
85
  ---
71
86
 
72
87
  # AI Execution Standard
@@ -1,6 +1,28 @@
1
1
  .git
2
+ .github
3
+ .claude
4
+ .husky
5
+ .idea
6
+ .vscode
2
7
  .env
3
8
  .env.*
4
- bin
5
- coverage.*
6
- node_modules
9
+ .npmrc
10
+ *.key
11
+ *.pem
12
+
13
+ bin/
14
+ coverage/
15
+ coverage.out
16
+ coverage.html
17
+ node_modules/
18
+ **/node_modules/
19
+ tmp/
20
+
21
+ *.log
22
+ .DS_Store
23
+
24
+ AGENTS.md
25
+ CLAUDE.md
26
+ README.md
27
+ deploy.config.json
28
+ docs/
@@ -0,0 +1,26 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "{{projectName}}",
4
+ "profile": "go-service",
5
+ "source": {
6
+ "verify": {
7
+ "executable": "make",
8
+ "args": ["check"]
9
+ },
10
+ "build": {
11
+ "executable": "make",
12
+ "args": ["build"]
13
+ }
14
+ },
15
+ "image": {
16
+ "dockerfile": "Dockerfile",
17
+ "context": ".",
18
+ "repository": "{{projectName}}",
19
+ "platform": "linux/amd64"
20
+ },
21
+ "environments": {
22
+ "production": {
23
+ "target": "registry-only"
24
+ }
25
+ }
26
+ }
@@ -112,8 +112,9 @@
112
112
  },
113
113
  {
114
114
  "target": "migrations/000001_infrastructure.up.sql",
115
- "target_template_sha256": "7805df78ce83bb5e7c7bdf8452b4da904d85c9251e25fa37cc24a87262b2d179",
116
- "transformation": "Extract seven generic baseline tables and complete command replay table as eight infrastructure owners, preserving fields, defaults, sequences, primary/unique/check constraints, indexes and comments. Keep four log partition parents and add default partitions. Preserve delivery identity and schema-version immutable triggers plus operation-log finalization guard. Omit platform event-catalog schema binding trigger; product producers assign their own schema_version without platform table queries.",
115
+ "target_template_sha256": "fe971c4bd743fda85d557c5f9634dae6347f5032414cb24d6e3f85dc5468457b",
116
+ "transformation": "source-adapted",
117
+ "details": "Defines eleven business-neutral infrastructure owner tables: seven retained OneX baseline tables, command replay with mandatory principal-scoped five-field identity, and three StackOnward-owned integration-event inbox, subscriber-receipt and aggregate-checkpoint tables. The three integration-event tables and their transition guards are not attributed to the listed OneX origins. Four log partition parents retain default partitions; outbox identity/schema immutability and operation-log finalization guards remain. The platform event-catalog binding trigger stays excluded because independent producers assign schema_version without platform database queries.",
117
118
  "origins": [
118
119
  {
119
120
  "source": "backend/migrations/000001_platform_baseline.up.sql",
@@ -127,8 +128,9 @@
127
128
  },
128
129
  {
129
130
  "target": "migrations/000001_infrastructure.down.sql",
130
- "target_template_sha256": "142598fb71a4e4b9586455495064ddaf62d0c67c3ffd95d7fa7c162b0a9abaca",
131
- "transformation": "Reverse only the eight product infrastructure owners, their dependent partitions/sequences and three retained guard functions. Keep shared pgcrypto extension.",
131
+ "target_template_sha256": "7e7b9e9682128e98eeee02cbbf4eafd4df255d67727896669e12244b76bc4cfc",
132
+ "transformation": "source-adapted",
133
+ "details": "Reverses the eleven business-neutral infrastructure owner tables and six associated guard functions; dependent partitions, triggers and sequences are removed through table cascade. The three integration-event tables and their guards are StackOnward-owned rather than derived from the listed OneX origins. The shared pgcrypto extension remains installed.",
132
134
  "origins": [
133
135
  {
134
136
  "source": "backend/migrations/000001_platform_baseline.up.sql",
@@ -160,8 +162,9 @@
160
162
  },
161
163
  {
162
164
  "target": "tests/infrastructure.sql",
163
- "target_template_sha256": "c7d445af3c6404ea664018f41111c93abaef606f6925f2fbd0b0ee9d6bfacc61",
164
- "transformation": "Eight-table presence, command replay unique/state constraints, outbox identity/schema immutability and operation-log append/finalization protection.",
165
+ "target_template_sha256": "342171c4af1ba35ab174439cb3673489b48f4e0073fa91d24939c9251e88f0a7",
166
+ "transformation": "source-adapted",
167
+ "details": "Template-owned verification asserts the exact eleven business-neutral infrastructure owner tables, five-field principal-scoped command replay uniqueness and required principal values, integration-event global identity and append-only storage, subscriber receipt isolation and terminal transitions, aggregate checkpoint source isolation and contiguous advancement, plus retained outbox and operation-log invariants.",
165
168
  "origins": []
166
169
  }
167
170
  ],
@@ -430,9 +430,10 @@
430
430
  {
431
431
  "source": "backend/internal/core/commandreplay/model.go",
432
432
  "target": "internal/core/commandreplay/model.go",
433
- "transformation": "module/import namespace only",
433
+ "transformation": "source-adapted",
434
+ "details": "Makes principal_type and principal_ref mandatory members of command replay identity. The database unique key is the five-field tuple (namespace, principal_type, principal_ref, command_scope, idempotency_key), isolating identical idempotency keys across trusted principals.",
434
435
  "source_sha256": "e610b620e96cabafad53ca1265455bd7fececcf6db73783beebe26bf6adf0c49",
435
- "target_template_sha256": "91f3fdddc489ce7249cd997584d1f61f06cb29a584523fcf30ac68c4fd0035aa"
436
+ "target_template_sha256": "537673b5ada9d6f8e375d76e65e76764162622f2867485e25b858cf15c8da823"
436
437
  },
437
438
  {
438
439
  "source": "backend/internal/core/commandreplay/model_test.go",
@@ -451,16 +452,18 @@
451
452
  {
452
453
  "source": "backend/internal/core/commandreplay/persistence.go",
453
454
  "target": "internal/core/commandreplay/persistence.go",
454
- "transformation": "module/import namespace only",
455
+ "transformation": "source-adapted",
456
+ "details": "Scopes repository lookup, conflict handling and the length-prefixed PostgreSQL advisory lock key by the five-field tuple (namespace, principal_type, principal_ref, command_scope, idempotency_key).",
455
457
  "source_sha256": "6b495eb7d886dea43f50fb5457c99450cf75ee3180ab138dc4b6f71d5e912308",
456
- "target_template_sha256": "fd8e034952709522d842dce4079f86ec4a2f8d499ea54f7d60c00758fca07784"
458
+ "target_template_sha256": "e335b9085acec0a83789e4e115ab0a04e2a6db8bae97384ab81a9d78e39debac"
457
459
  },
458
460
  {
459
461
  "source": "backend/internal/core/commandreplay/persistence_test.go",
460
462
  "target": "internal/core/commandreplay/persistence_test.go",
461
- "transformation": "module/import namespace only",
463
+ "transformation": "source-adapted",
464
+ "details": "Verifies database lookup, conflict targets and advisory locks use the complete five-field principal scope and do not collide across distinct principals.",
462
465
  "source_sha256": "58228cdb9f0fcf22da36e620d565d810fe3c618e061a68e61a140da44bacac3b",
463
- "target_template_sha256": "1cff76682460690e89b5c80f58f0422c02cfb1319ed079447a2c9104e9104107"
466
+ "target_template_sha256": "0947eab5e124ebd4168a78cc3a2ed9957462a6fed1d3967c4672f590644db760"
464
467
  },
465
468
  {
466
469
  "source": "backend/internal/core/commandreplay/provider.go",
@@ -472,37 +475,42 @@
472
475
  {
473
476
  "source": "backend/internal/core/commandreplay/repository.go",
474
477
  "target": "internal/core/commandreplay/repository.go",
475
- "transformation": "module/import namespace only",
478
+ "transformation": "source-adapted",
479
+ "details": "Extends replay lookup and scope-lock contracts with required principal type and principal reference so every repository operation addresses the same five-field identity.",
476
480
  "source_sha256": "90a0e737c8df5f65e6f1897c82b3087ef35a6cbf1e46bd4ca291ce18a686610f",
477
- "target_template_sha256": "90a0e737c8df5f65e6f1897c82b3087ef35a6cbf1e46bd4ca291ce18a686610f"
481
+ "target_template_sha256": "4e52704849a4bbc1842e812d6a6244d0860f23a73a92ae7b27694cf1fa2bc617"
478
482
  },
479
483
  {
480
484
  "source": "backend/internal/core/commandreplay/service.go",
481
485
  "target": "internal/core/commandreplay/service.go",
482
- "transformation": "module/import namespace only",
486
+ "transformation": "source-adapted",
487
+ "details": "Requires trusted principal type and reference at the service boundary and propagates the complete five-field scope through lock, lookup, create and duplicate-reload paths.",
483
488
  "source_sha256": "e6773b4fa604133333ca193d73c7ede037abf7d00df2293df4618e22cd2b616c",
484
- "target_template_sha256": "6a80ef4df446a6bb10da4972890fdbd166d879bd4a01368f9605a28e781b197e"
489
+ "target_template_sha256": "266dd3787265091404af83be76c26d887b5115abddaf97f3ce3b76b3c456b093"
485
490
  },
486
491
  {
487
492
  "source": "backend/internal/core/commandreplay/service_persistence_test.go",
488
493
  "target": "internal/core/commandreplay/service_persistence_test.go",
489
- "transformation": "module/import namespace only",
494
+ "transformation": "source-adapted",
495
+ "details": "Verifies persisted replay isolation for identical command and idempotency values owned by different trusted principals, including concurrent acquisition behavior.",
490
496
  "source_sha256": "1e5e2fd1b720f069f926c5cfc009e243606b0e87bccd6773a71ee53cc8b7e02b",
491
- "target_template_sha256": "05b14b08732cae67d54093953936d1531b75bc2ee2b3aa86e48c2bbacacb9e0c"
497
+ "target_template_sha256": "47ecb12805fe8efa1d59cbcf22bc03ae971f8bb2725a418d2cfc416b27785a19"
492
498
  },
493
499
  {
494
500
  "source": "backend/internal/core/commandreplay/service_test.go",
495
501
  "target": "internal/core/commandreplay/service_test.go",
496
- "transformation": "module/import namespace only",
502
+ "transformation": "source-adapted",
503
+ "details": "Verifies principal fields are mandatory and that replay, conflict and external-lease behavior consistently use the five-field principal scope.",
497
504
  "source_sha256": "f022ef1d41d9d296af275ef335ac87431c5f55307d19c7d9252a1b7e3f71116b",
498
- "target_template_sha256": "2b6cdf86e4bf1020552a1009ee1500e68e1b1804f19ddd5f35caa53688cf9051"
505
+ "target_template_sha256": "24ff2e89c58fe24447051fac0b03000a6e608317366a5ccbe73761570cde47af"
499
506
  },
500
507
  {
501
508
  "source": "backend/internal/core/commandreplay/types.go",
502
509
  "target": "internal/core/commandreplay/types.go",
503
- "transformation": "module/import namespace only",
510
+ "transformation": "source-adapted",
511
+ "details": "Makes principal type and principal reference required request and normalized values because both fields participate in command replay identity.",
504
512
  "source_sha256": "14c038c1f4cf4ff076e0b71727276604550dd62557e7e2c72b8e4d38aa179c04",
505
- "target_template_sha256": "51b67144ae463a38ecf4b01729f91f130f4b0deccc60b601f51981779be84686"
513
+ "target_template_sha256": "53c7278ebea7ed97ef2817c30feb8a60846336b8d79886d08a046396d81b36fa"
506
514
  },
507
515
  {
508
516
  "source": "backend/internal/core/config/config.go",
@@ -2175,9 +2183,10 @@
2175
2183
  {
2176
2184
  "source": "backend/internal/common/enums/register_locale.go",
2177
2185
  "target": "internal/common/enums/register_locale.go",
2178
- "transformation": "module/import namespace only",
2186
+ "transformation": "source-adapted",
2187
+ "details": "Expands business-neutral locale presentation metadata to the exact 19 internal short codes exposed by the locale Resource Bundle; no alias codes are registered.",
2179
2188
  "source_sha256": "3334a68915c6a6c10b5ff1e36bc523e4111a962b451779165cfb70c0e6e4837d",
2180
- "target_template_sha256": "1210020d94c87af0a78146a6f782352a87d25a9581514d6f8c413f64e6c62f98"
2189
+ "target_template_sha256": "62ab6b920cd7e394c99dd0855bc0479e70183bc0f631e0c2e5a83826791a694a"
2181
2190
  },
2182
2191
  {
2183
2192
  "source": "backend/internal/common/enums/register_mailbox.go",
@@ -2546,16 +2555,18 @@
2546
2555
  {
2547
2556
  "source": "backend/internal/common/locale/locale.go",
2548
2557
  "target": "internal/common/locale/locale.go",
2549
- "transformation": "module/import namespace only",
2558
+ "transformation": "source-adapted",
2559
+ "details": "Replaces permissive case-folded aliases and fallback normalization with one exact 19-entry Resource Bundle mapping between canonical external tags and internal short codes. It provides deterministic round trips and text direction, marks only Arabic as RTL, and rejects unknown, non-canonical or colliding locale keys.",
2550
2560
  "source_sha256": "17b5db8f7f0d0667cc88e37351435214e04ad98fe214b22c1c857e77ef7cb02d",
2551
- "target_template_sha256": "17b5db8f7f0d0667cc88e37351435214e04ad98fe214b22c1c857e77ef7cb02d"
2561
+ "target_template_sha256": "bafde441fe8a3d7d0e6db8c413a9d562030cfb2145357fc2406b6d831b2f77a5"
2552
2562
  },
2553
2563
  {
2554
2564
  "source": "backend/internal/common/locale/locale_test.go",
2555
2565
  "target": "internal/common/locale/locale_test.go",
2556
- "transformation": "module/import namespace only",
2566
+ "transformation": "source-adapted",
2567
+ "details": "Verifies all 19 canonical external-tag and short-code round trips and directions, plus fail-closed rejection of aliases, case variants, whitespace, punctuation, unknown keys, blank text and duplicate canonical targets.",
2557
2568
  "source_sha256": "67c1b6b955603c3797d8231b916ce5ee3f55c7463a600f6bc931a9e2ad706cbc",
2558
- "target_template_sha256": "67c1b6b955603c3797d8231b916ce5ee3f55c7463a600f6bc931a9e2ad706cbc"
2569
+ "target_template_sha256": "5f76e478cdaa12a2e6bcea3ddb8501e7473530efff0f628d4f77d82364809946"
2559
2570
  },
2560
2571
  {
2561
2572
  "source": "backend/internal/common/mediaref/mediaref.go",
@@ -18,8 +18,15 @@ var localeEnumMetadataByCode = map[string]localeEnumMetadata{
18
18
  "ja": {label: "日本語", color: "magenta"},
19
19
  "ko": {label: "한국어", color: "green"},
20
20
  "pt": {label: "Português", color: "orange"},
21
+ "ru": {label: "Русский", color: "purple"},
21
22
  "hi": {label: "हिंदी", color: "lime"},
22
23
  "ar": {label: "العربية", color: "volcano"},
24
+ "id": {label: "Bahasa Indonesia", color: "blue"},
25
+ "th": {label: "ไทย", color: "green"},
26
+ "vi": {label: "Tiếng Việt", color: "cyan"},
27
+ "tr": {label: "Türkçe", color: "red"},
28
+ "pl": {label: "Polski", color: "magenta"},
29
+ "nl": {label: "Nederlands", color: "geekblue"},
23
30
  }
24
31
 
25
32
  func commonLocaleEnumItems() []EnumItem {
@@ -0,0 +1,30 @@
1
+ package enums
2
+
3
+ import (
4
+ "reflect"
5
+ "testing"
6
+
7
+ commonlocale "{{module}}/internal/common/locale"
8
+ )
9
+
10
+ func TestCommonLocaleEnumItemsCoverSupportedCodes(t *testing.T) {
11
+ items := commonLocaleEnumItems()
12
+ wantCodes := commonlocale.SupportedCodes()
13
+ if len(items) != len(wantCodes) {
14
+ t.Fatalf("commonLocaleEnumItems() count = %d, want %d", len(items), len(wantCodes))
15
+ }
16
+
17
+ gotCodes := make([]string, 0, len(items))
18
+ for _, item := range items {
19
+ gotCodes = append(gotCodes, item.Value)
20
+ if item.Label == "" {
21
+ t.Errorf("locale %q has empty label", item.Value)
22
+ }
23
+ if item.Color == "" {
24
+ t.Errorf("locale %q has empty color", item.Value)
25
+ }
26
+ }
27
+ if !reflect.DeepEqual(gotCodes, wantCodes) {
28
+ t.Fatalf("commonLocaleEnumItems() values = %#v, want %#v", gotCodes, wantCodes)
29
+ }
30
+ }
@@ -2,6 +2,7 @@ package locale
2
2
 
3
3
  import (
4
4
  "encoding/json"
5
+ "fmt"
5
6
  "strings"
6
7
  )
7
8
 
@@ -10,124 +11,143 @@ const (
10
11
  AdminDefaultCode = "cn"
11
12
  )
12
13
 
13
- var supportedCodes = []string{
14
- "en",
15
- "cn",
16
- "tw",
17
- "de",
18
- "es",
19
- "fr",
20
- "it",
21
- "ja",
22
- "ko",
23
- "pt",
24
- "hi",
25
- "ar",
14
+ // Direction 表示语言内容的排版方向。
15
+ type Direction string
16
+
17
+ const (
18
+ DirectionLTR Direction = "ltr"
19
+ DirectionRTL Direction = "rtl"
20
+ )
21
+
22
+ // Mapping 定义规范外部语言标签与内部短码之间的唯一映射。
23
+ type Mapping struct {
24
+ ExternalTag string
25
+ ShortCode string
26
+ Direction Direction
26
27
  }
27
28
 
28
- var supportedCodeSet = buildSupportedCodeSet()
29
+ var mappings = []Mapping{
30
+ {ExternalTag: "en", ShortCode: "en", Direction: DirectionLTR},
31
+ {ExternalTag: "zh-Hans", ShortCode: "cn", Direction: DirectionLTR},
32
+ {ExternalTag: "zh-Hant", ShortCode: "tw", Direction: DirectionLTR},
33
+ {ExternalTag: "ja", ShortCode: "ja", Direction: DirectionLTR},
34
+ {ExternalTag: "ko", ShortCode: "ko", Direction: DirectionLTR},
35
+ {ExternalTag: "es", ShortCode: "es", Direction: DirectionLTR},
36
+ {ExternalTag: "fr", ShortCode: "fr", Direction: DirectionLTR},
37
+ {ExternalTag: "de", ShortCode: "de", Direction: DirectionLTR},
38
+ {ExternalTag: "pt-BR", ShortCode: "pt", Direction: DirectionLTR},
39
+ {ExternalTag: "it", ShortCode: "it", Direction: DirectionLTR},
40
+ {ExternalTag: "ru", ShortCode: "ru", Direction: DirectionLTR},
41
+ {ExternalTag: "ar", ShortCode: "ar", Direction: DirectionRTL},
42
+ {ExternalTag: "hi", ShortCode: "hi", Direction: DirectionLTR},
43
+ {ExternalTag: "id", ShortCode: "id", Direction: DirectionLTR},
44
+ {ExternalTag: "th", ShortCode: "th", Direction: DirectionLTR},
45
+ {ExternalTag: "vi", ShortCode: "vi", Direction: DirectionLTR},
46
+ {ExternalTag: "tr", ShortCode: "tr", Direction: DirectionLTR},
47
+ {ExternalTag: "pl", ShortCode: "pl", Direction: DirectionLTR},
48
+ {ExternalTag: "nl", ShortCode: "nl", Direction: DirectionLTR},
49
+ }
50
+
51
+ var externalTagIndex, shortCodeIndex = buildIndexes()
29
52
 
30
- func buildSupportedCodeSet() map[string]struct{} {
31
- result := make(map[string]struct{}, len(supportedCodes))
32
- for _, code := range supportedCodes {
33
- result[code] = struct{}{}
53
+ func buildIndexes() (map[string]Mapping, map[string]Mapping) {
54
+ byExternalTag := make(map[string]Mapping, len(mappings))
55
+ byShortCode := make(map[string]Mapping, len(mappings))
56
+ for _, mapping := range mappings {
57
+ byExternalTag[mapping.ExternalTag] = mapping
58
+ byShortCode[mapping.ShortCode] = mapping
34
59
  }
60
+ return byExternalTag, byShortCode
61
+ }
62
+
63
+ // Mappings 返回按稳定顺序排列的全部语言映射。
64
+ func Mappings() []Mapping {
65
+ result := make([]Mapping, len(mappings))
66
+ copy(result, mappings)
35
67
  return result
36
68
  }
37
69
 
70
+ // SupportedCodes 返回按稳定顺序排列的全部内部短码。
38
71
  func SupportedCodes() []string {
39
- result := make([]string, len(supportedCodes))
40
- copy(result, supportedCodes)
72
+ result := make([]string, 0, len(mappings))
73
+ for _, mapping := range mappings {
74
+ result = append(result, mapping.ShortCode)
75
+ }
41
76
  return result
42
77
  }
43
78
 
44
- func NormalizeCode(value string) string {
45
- code := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "_", "-")))
46
- switch code {
47
- case "en", "en-us", "en-gb":
48
- return "en"
49
- case "cn", "zh", "zh-cn", "zh-hans", "zh-hans-cn":
50
- return "cn"
51
- case "tw", "hk", "zh-tw", "zh-hk", "zh-hant", "zh-hant-tw", "zh-hant-hk":
52
- return "tw"
53
- case "ja", "jp", "ja-jp":
54
- return "ja"
55
- case "ko", "ko-kr":
56
- return "ko"
57
- case "de", "de-de":
58
- return "de"
59
- case "es", "es-es":
60
- return "es"
61
- case "fr", "fr-fr":
62
- return "fr"
63
- case "it", "it-it":
64
- return "it"
65
- case "pt", "pt-br", "pt-pt":
66
- return "pt"
67
- case "hi", "in", "hi-in":
68
- return "hi"
69
- case "ar", "ar-sa":
70
- return "ar"
71
- }
72
- return ""
79
+ // ShortCodeForExternalTag 将规范外部语言标签精确映射为内部短码。
80
+ func ShortCodeForExternalTag(externalTag string) (string, bool) {
81
+ mapping, ok := externalTagIndex[externalTag]
82
+ return mapping.ShortCode, ok
73
83
  }
74
84
 
75
- func NormalizeCodeOrDefault(value string, fallback string) string {
76
- if code := NormalizeCode(value); code != "" {
77
- return code
85
+ // ExternalTagForShortCode 将内部短码精确映射为规范外部语言标签。
86
+ func ExternalTagForShortCode(shortCode string) (string, bool) {
87
+ mapping, ok := shortCodeIndex[shortCode]
88
+ return mapping.ExternalTag, ok
89
+ }
90
+
91
+ // DirectionForShortCode 返回内部短码对应的排版方向。
92
+ func DirectionForShortCode(shortCode string) (Direction, bool) {
93
+ mapping, ok := shortCodeIndex[shortCode]
94
+ return mapping.Direction, ok
95
+ }
96
+
97
+ // NormalizeCode 将规范外部语言标签或内部短码精确转换为内部短码。
98
+ func NormalizeCode(value string) string {
99
+ if mapping, ok := externalTagIndex[value]; ok {
100
+ return mapping.ShortCode
78
101
  }
79
- if code := NormalizeCode(fallback); code != "" {
80
- return code
102
+ if mapping, ok := shortCodeIndex[value]; ok {
103
+ return mapping.ShortCode
81
104
  }
82
- return DefaultCode
105
+ return ""
83
106
  }
84
107
 
108
+ // IsShortCode 判断值是否为受支持的精确内部短码。
85
109
  func IsShortCode(value string) bool {
86
- code := strings.TrimSpace(value)
87
- if code == "" || code != strings.ToLower(code) {
88
- return false
89
- }
90
- _, ok := supportedCodeSet[code]
110
+ _, ok := shortCodeIndex[value]
91
111
  return ok
92
112
  }
93
113
 
94
- func NormalizeTextMap(input map[string]string) map[string]string {
95
- if len(input) == 0 {
96
- return map[string]string{}
97
- }
114
+ // NormalizeTextMap 将语言键精确转换为内部短码并拒绝无效或冲突输入。
115
+ func NormalizeTextMap(input map[string]string) (map[string]string, error) {
98
116
  result := make(map[string]string, len(input))
99
117
  for key, value := range input {
100
118
  code := NormalizeCode(key)
119
+ if code == "" {
120
+ return nil, fmt.Errorf("unsupported locale key %q", key)
121
+ }
101
122
  text := strings.TrimSpace(value)
102
- if code == "" || text == "" {
103
- continue
123
+ if text == "" {
124
+ return nil, fmt.Errorf("locale %q has empty text", key)
104
125
  }
105
126
  if _, exists := result[code]; exists {
106
- continue
127
+ return nil, fmt.Errorf("duplicate locale mapping for short code %q", code)
107
128
  }
108
129
  result[code] = text
109
130
  }
110
- return result
131
+ return result, nil
111
132
  }
112
133
 
113
- func NormalizeLocaleRootedMap[T any](input map[string]T) map[string]T {
114
- if len(input) == 0 {
115
- return map[string]T{}
116
- }
134
+ // NormalizeLocaleRootedMap 将语言根键精确转换为内部短码并拒绝无效或冲突输入。
135
+ func NormalizeLocaleRootedMap[T any](input map[string]T) (map[string]T, error) {
117
136
  result := make(map[string]T, len(input))
118
137
  for key, value := range input {
119
138
  code := NormalizeCode(key)
120
139
  if code == "" {
121
- continue
140
+ return nil, fmt.Errorf("unsupported locale key %q", key)
122
141
  }
123
142
  if _, exists := result[code]; exists {
124
- continue
143
+ return nil, fmt.Errorf("duplicate locale mapping for short code %q", code)
125
144
  }
126
145
  result[code] = value
127
146
  }
128
- return result
147
+ return result, nil
129
148
  }
130
149
 
150
+ // NormalizeRawTextMap 将 JSON 文本映射规范化为内部短码键。
131
151
  func NormalizeRawTextMap(raw json.RawMessage) (json.RawMessage, error) {
132
152
  if len(raw) == 0 {
133
153
  return json.RawMessage(`{}`), nil
@@ -136,7 +156,13 @@ func NormalizeRawTextMap(raw json.RawMessage) (json.RawMessage, error) {
136
156
  if err := json.Unmarshal(raw, &input); err != nil {
137
157
  return nil, err
138
158
  }
139
- normalized := NormalizeTextMap(input)
159
+ if input == nil {
160
+ return nil, fmt.Errorf("locale text map must be a JSON object")
161
+ }
162
+ normalized, err := NormalizeTextMap(input)
163
+ if err != nil {
164
+ return nil, err
165
+ }
140
166
  payload, err := json.Marshal(normalized)
141
167
  if err != nil {
142
168
  return nil, err