diff --git a/.circleci/config.yml b/.circleci/config.yml index c7bd1bd91..30f026dcf 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,6 +144,15 @@ jobs: rm terraform_${TERRAFORM_VERSION}_linux_amd64.zip environment: TERRAFORM_VERSION: 1.11.4 + - run: + name: Clear stale state lock (if present) + command: | + cd ./infra/src + terraform init \ + -backend-config="bucket=docs.mojaloop.io-state" \ + -backend-config="region=eu-west-2" \ + -backend-config="dynamodb_table=docs.mojaloop.io-lock" + terraform force-unlock -force 7232e661-3027-02c3-49da-f8cd72eefc89 || true - run: name: Update infrastructure command: | diff --git a/.github/workflows/i18n-translation-drift.yml b/.github/workflows/i18n-translation-drift.yml new file mode 100644 index 000000000..d7ade58aa --- /dev/null +++ b/.github/workflows/i18n-translation-drift.yml @@ -0,0 +1,139 @@ +name: i18n translation drift + +on: + pull_request: + paths: + - "docs/**/*.md" + - "docs/.vuepress/config.js" + - "scripts/i18n-drift-check.mjs" + push: + branches: + - master + paths: + - "docs/**/*.md" + - "docs/.vuepress/config.js" + - "scripts/i18n-drift-check.mjs" + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + report-pr: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate i18n drift report + run: | + node scripts/i18n-drift-check.mjs \ + --base "${{ github.event.pull_request.base.sha }}" \ + --head "${{ github.event.pull_request.head.sha }}" \ + --json-out i18n-report.json \ + --markdown-out i18n-report.md + + - name: Upsert pull request comment + uses: actions/github-script@v7 + with: + script: | + const fs = require("fs"); + const body = fs.readFileSync("i18n-report.md", "utf8"); + const marker = ""; + const finalBody = `${marker}\n${body}`; + + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + + const existing = comments.find((comment) => + comment.user?.type === "Bot" && comment.body?.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body: finalBody, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: finalBody, + }); + } + + - name: Upload i18n report artifact + uses: actions/upload-artifact@v4 + with: + name: i18n-report-pr + path: | + i18n-report.json + i18n-report.md + + create-issue-on-master: + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate i18n drift report + run: | + node scripts/i18n-drift-check.mjs \ + --base "${{ github.event.before }}" \ + --head "${{ github.sha }}" \ + --json-out i18n-report.json \ + --markdown-out i18n-report.md + + - name: Create issue when drift exists + uses: actions/github-script@v7 + with: + script: | + const fs = require("fs"); + const report = JSON.parse(fs.readFileSync("i18n-report.json", "utf8")); + const md = fs.readFileSync("i18n-report.md", "utf8"); + + if (!report.hasDrift) { + core.info("No translation drift detected; no issue created."); + return; + } + + const title = `i18n: translation updates required for ${context.sha.slice(0, 7)}`; + const body = [ + "This issue was auto-created after source documentation changes were merged to `master`.", + "", + md, + "", + "Please coordinate locale updates with maintainers/reviewers.", + ].join("\n"); + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + }); + + - name: Upload i18n report artifact + uses: actions/upload-artifact@v4 + with: + name: i18n-report-master + path: | + i18n-report.json + i18n-report.md diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 1a14e2b5b..d4e389bf1 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -217,6 +217,7 @@ module.exports = { ['standards/versioning', 'Versioning'], ['standards/creating-new-features', 'Creating New Features'], ['standards/triaging-bugs', 'Triaging Bugs'], + ['standards/ai_policy', "AI Policy"], ] }, { diff --git a/docs/community/contributing/contributors-guide.md b/docs/community/contributing/contributors-guide.md index 9e5dfc416..06a839db3 100644 --- a/docs/community/contributing/contributors-guide.md +++ b/docs/community/contributing/contributors-guide.md @@ -2,6 +2,9 @@ We are glad that you are considering becoming a part of the Mojaloop community. +_Note: If you use or are planning to use Artificial Intelligence (AI) tools as part of your Mojaloop contribution workflow, +please ensure you have fully read and comply with our [responsible use of AI policy](../standards/ai_policy.md)._ + Based on the current phase of the Mojaloop project, we are looking for the following types of contributors: ## Types of contributors @@ -83,4 +86,9 @@ a production mobile money provider. ## Where do I send bugs, questions, and feedback? -For bugs, see [Reporting bugs](https://github.com/mojaloop/mojaloop/blob/master/contribute/Reporting-Bugs.md). \ No newline at end of file +For bugs, see [Reporting bugs](https://github.com/mojaloop/mojaloop/blob/master/contribute/Reporting-Bugs.md). + +## Policy on the Use of Artificial Intelligence (AI) Tools by Community Members + +If you use or are planning to use Artificial Intelligence (AI) tools as part of your Mojaloop contribution workflow, +please ensure you have fully read and comply with our [responsible use of AI policy](../standards/ai_policy.md). \ No newline at end of file diff --git a/docs/community/standards/ai_policy.md b/docs/community/standards/ai_policy.md new file mode 100644 index 000000000..344575b06 --- /dev/null +++ b/docs/community/standards/ai_policy.md @@ -0,0 +1,163 @@ +# Policy on the Responsible Use of Artificial Intelligence (AI) Tools by Community Members + +- Version: 1.0 +- Effective Date: 2026-04-08 +- Author: James Bush (jbush@mojaloop.io) +- Applies To: All contributors, maintainers, adopters, and participants in the Mojaloop community and its associated projects, including repositories under the Mojaloop GitHub organisation. + +**AI Disclosure** This document includes content generated with assistance from ChatGPT 5.2. All content has been reviewed and validated by the author. + +--- + +## 1. Purpose + +This policy establishes clear and pragmatic guidelines for the responsible use of Artificial Intelligence (AI) tools within the Mojaloop community. + +The Mojaloop Foundation supports innovation and productivity enhancements, including the use of AI-assisted tools. However, transparency, accountability, and community trust remain paramount. This policy ensures that AI use enhances collaboration without undermining openness, authorship integrity, or technical quality. + +--- + +## 2. Guiding Principles + +All AI use within the Mojaloop community must adhere to the following principles: + +1. **Human Accountability** – A human contributor is always responsible for the final output. +2. **Transparency** – Use of AI-generated content must be clearly disclosed. +3. **Quality and Security** – AI-generated outputs must meet Mojaloop’s engineering and documentation standards. +4. **Community Integrity** – AI must not be used in ways that disrupt or overwhelm community processes. + +--- + +## 3. Permitted Uses of AI Tools + +### 3.1 AI as Note-Takers in Community Calls + +AI tools may be used to take notes during **public Mojaloop community calls**, subject to the following conditions: + +- The AI tool user **must be personally present** in the call unless prior authorisation is obtained from the meeting host. +- AI note-taking tools may not join calls independently of a human participant without explicit prior authorisation from the meeting host. +- Anonymous AI bots are not permitted. All AI bots must disclose publicly the human community member they represent. +- AI note-taking tools may only join calls where call recording is enabled. + +**Rationale:** +The Mojaloop community values open discussion and psychological safety. The presence of numerous unattended recording or summarisation bots may discourage participation and negatively affect collaboration. + +--- + +### 3.2 AI Assistance in Documentation + +Community members may use AI tools to assist with: + +- Drafting documentation +- Improving clarity or grammar +- Reformatting content +- Generating summaries +- Translating content + +However: + +- Any document in which AI has generated **any portion of the content** must contain a clear statement in the document header specifying: + - That AI tools were used + - Which AI tool(s) were used + +**Example Disclosure Statement:** + + _This document includes content generated with assistance from [Tool Name]. All content has been reviewed and validated by the author._ + +Failure to disclose AI-assisted generation may result in the document being rejected or returned for correction. + +**Rationale:** +Transparency maintains trust in authorship and allows readers to assess provenance appropriately. + +--- + +### 3.3 AI Assistance in Code Creation and Debugging + +AI tools may be used for: + +- Code generation +- Code suggestions +- Refactoring assistance +- Debugging support +- Test generation +- Documentation generation for code + +However, the following rules strictly apply: + +#### 3.3.1 Human Submission Requirement + +- All pull requests (PRs), issues, and code submissions must be made by human contributors. +- Fully automated AI agents may not submit PRs, bug fixes, or code changes. +- All pull requests (PRs), issues, and code submissions must follow the Mojaloop community product engineering process requirements. +- The only exception is officially supported automated tools already integrated into GitHub workflows (e.g., dependency update bots such as Dependabot). + +Any automated agent submissions beyond approved GitHub-native tools will be **dismissed without review**. + +--- + +#### 3.3.2 Mandatory Human Review + +All AI-assisted code: + +- MUST be thoroughly reviewed by the human submitter. +- MUST be understood in full by the submitter. +- MUST meet Mojaloop coding standards and architectural principles. +- MUST pass all automated tests and validation pipelines. + +Code that is clearly AI-generated and has not been properly reviewed, validated, and understood by the human author will not be accepted into the codebase. + +The human contributor submitting the PR retains full accountability for: + +- Correctness +- Security +- Licensing compliance +- Architectural consistency +- Long-term maintainability + +**Rationale:** +Mojaloop operates in the financial services domain. The integrity, security, and correctness of code are non-negotiable. + +--- + +## 4. Prohibited Uses + +The following uses of AI are not permitted within Mojaloop community processes: + +- Unattended AI bots joining community calls. +- Fully autonomous AI agents submitting PRs or issues. +- Submitting AI-generated content without required disclosure (where applicable). +- Delegating architectural or design decisions to AI tools. +- Using AI tools to scrape, summarise, or redistribute restricted or confidential information without permission. + +--- + +## 5. Enforcement + +Maintainers and reviewers may: + +- Request disclosure statements be added. +- Reject PRs that appear insufficiently reviewed. +- Close automated agent submissions without comment. +- Request clarification regarding AI involvement. + +Repeated or deliberate violations may be escalated in accordance with Mojaloop community governance procedures. + +--- + +## 6. Future Review + +AI capabilities evolve rapidly. This policy will be reviewed periodically by the Mojaloop Foundation and community maintainers to ensure it remains appropriate, practical, and aligned with community values. + +--- + +## 7. Summary + +AI tools are permitted within the Mojaloop community when used responsibly and transparently. + +- Humans must remain accountable. +- AI must not overwhelm community processes. +- Disclosure is required in documentation. +- Code must always be reviewed and submitted by a human. + +The Mojaloop Foundation encourages thoughtful adoption of AI tools in ways that strengthen, not dilute, the quality, trust, and collaborative spirit of the Mojaloop ecosystem. + diff --git a/docs/technical/api/fspiop/pki-best-practices.md b/docs/technical/api/fspiop/pki-best-practices.md index 5de85cb0d..8fcd2eac9 100644 --- a/docs/technical/api/fspiop/pki-best-practices.md +++ b/docs/technical/api/fspiop/pki-best-practices.md @@ -24,6 +24,7 @@ The following conventions are used in this document to identify the specified ty |Version|Date|Change Description| |---|---|---| |**1.0**|2018-03-13|Initial version| +|**2.0**|2026-04-08|Second version published to match FSPIOP v2.0 document set| ## Introduction @@ -408,7 +409,9 @@ This section describes the application layer protection. The _JSON Web Signature_ (JWS) standard is used for providing end-to-end integrity and non-repudiation; that is, to guarantee that the sender is who it claims to be, and that the message was not tampered with. -The use of JWS is mandatory and certificates should be used. For more information, see _API Signature_. +The use of JWS is mandatory and certificates must be used. For more information, see _API Signature_. + +JWS's core purpose includes non-repudiation i.e., proving who signed a message. A bare symmetric key (HMAC) can prove a message wasn't tampered with, but can't prove which party signed it since both sides share the same key. Only an asymmetric certificate tied to a CA-verified identity achieves true non-repudiation. The entire PKI model - where each platform trusts a shared CA only works if JWS signatures are certificate-backed. Without it, a platform could generate any key pair and claim any identity. Certificates anchor identity to the CA's vetting process. ### JSON Web Encryption diff --git a/package-lock.json b/package-lock.json index db4d128de..353d59d45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,10 @@ "devDependencies": { "@vuepress/plugin-back-to-top": "^1.9.10", "@vuepress/plugin-medium-zoom": "^1.9.10", - "got": "^15.0.0", + "got": "^15.0.2", "husky": "^9.1.7", "markdownlint-cli": "^0.48.0", - "npm-check-updates": "^20.0.0", + "npm-check-updates": "^21.0.1", "plantuml-encoder": "^1.4.0", "svgo": "^4.0.1", "vuepress": "^1.9.10", @@ -1830,8 +1830,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@mrmlnc/readdir-enhanced": { "version": "2.2.1", @@ -1861,15 +1860,13 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -1960,8 +1957,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/http-proxy": { "version": "1.17.11", @@ -3889,7 +3885,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, @@ -3902,7 +3897,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT", "engines": { "node": "18 || 20 || >=22" } @@ -4311,7 +4305,6 @@ "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.18.tgz", "integrity": "sha512-rFWadDRKJs3s2eYdXlGggnBZKG7MTblkFBB0YllFds+UYnfogDp2wcR6JN97FhRkHTvq59n2vhNoHNZn29dh/Q==", "dev": true, - "license": "MIT", "dependencies": { "@types/http-cache-semantics": "^4.0.4", "get-stream": "^9.0.1", @@ -4783,7 +4776,6 @@ "resolved": "https://registry.npmjs.org/chunk-data/-/chunk-data-0.1.0.tgz", "integrity": "sha512-zFyPtyC0SZ6Zu79b9sOYtXZcgrsXe0RpePrzRyj52hYVFG1+Rk6rBqjjOEk+GNQwc3PIX+86teQMok970pod1g==", "dev": true, - "license": "MIT", "engines": { "node": ">=20" }, @@ -5180,7 +5172,6 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "dev": true, - "license": "MIT", "engines": { "node": ">=20" } @@ -9420,7 +9411,6 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "dev": true, - "license": "MIT", "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" @@ -9437,7 +9427,6 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -9646,9 +9635,9 @@ } }, "node_modules/got": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/got/-/got-15.0.0.tgz", - "integrity": "sha512-CUqLG9oFZRis7SZq5Bcxh42LpzxXgXwxWVwNljo60oki8Cq3GXVRpDY2K4GwTzYz3htyXf212nfNg2socz4esQ==", + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/got/-/got-15.0.2.tgz", + "integrity": "sha512-opPIdoQSTOGqX3h0d8QQoqCX0oF728V1PFxbPqquAFSeWPUOlPQ3Gi6IKIqGm6NMcDl+DWUwQb2RR+xvuEJOQQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11399,7 +11388,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, - "license": "MIT", "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -11649,7 +11637,6 @@ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "dev": true, - "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -11764,7 +11751,6 @@ "resolved": "https://registry.npmjs.org/markdownlint-cli/-/markdownlint-cli-0.48.0.tgz", "integrity": "sha512-NkZQNu2E0Q5qLEEHwWj674eYISTLD4jMHkBzDobujXd1kv+yCxi8jOaD/rZoQNW1FBBMMGQpuW5So8B51N/e0A==", "dev": true, - "license": "MIT", "dependencies": { "commander": "~14.0.3", "deep-extend": "~0.6.0", @@ -12622,13 +12608,12 @@ "dev": true }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -13046,7 +13031,6 @@ "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=14.16" }, @@ -13055,9 +13039,9 @@ } }, "node_modules/npm-check-updates": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-20.0.0.tgz", - "integrity": "sha512-qCs02x51irGf0okCttwv8lHEO2NxT903IJ2bKpG82kIzkm6pfT3CoWB5YIvqY/wi/DdnYRfI7eVfCYYymQgvCg==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-21.0.1.tgz", + "integrity": "sha512-BwYXOxntt5uQsLHcKw/z1awNAW1USfzQwMiBoKyCMlFi7hIVQKiMv3TJvJaHppZxmYkjvhnuogroN374Jsupvw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -13065,8 +13049,8 @@ "npm-check-updates": "build/cli.js" }, "engines": { - "node": ">=20.0.0", - "npm": ">=8.12.1" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10.0.0" } }, "node_modules/npm-run-path": { @@ -16472,7 +16456,6 @@ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", "dev": true, - "license": "BSD-3-Clause", "engines": { "node": ">= 18" }, @@ -17426,7 +17409,6 @@ "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", "dev": true, - "license": "MIT", "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", @@ -17462,7 +17444,6 @@ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", "dev": true, - "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" } @@ -17472,7 +17453,6 @@ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", "dev": true, - "license": "MIT", "engines": { "node": ">=20" }, @@ -18118,7 +18098,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", "dev": true, - "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" }, @@ -18258,7 +18237,6 @@ "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, diff --git a/package.json b/package.json index 1c44f98d7..38df3fc69 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "scripts": { "dev": "NODE_OPTIONS=--openssl-legacy-provider npx npx vuepress dev docs", "build": "NODE_OPTIONS='--max-old-space-size=8192' npx npx vuepress build docs", + "i18n:check": "node scripts/i18n-drift-check.mjs --base origin/master --head HEAD --markdown-out i18n-report.md --json-out i18n-report.json", "build:plantuml:all": "./scripts/_build_plantuml.sh", "build:plantuml:diff": "MODE=STAGED_GIT ./scripts/_build_plantuml.sh", "lint": "npx markdownlint './docs/**/*.md' --ignore node_modules --config markdownlint.yaml", @@ -24,10 +25,10 @@ "devDependencies": { "@vuepress/plugin-back-to-top": "^1.9.10", "@vuepress/plugin-medium-zoom": "^1.9.10", - "got": "^15.0.0", + "got": "^15.0.2", "husky": "^9.1.7", "markdownlint-cli": "^0.48.0", - "npm-check-updates": "^20.0.0", + "npm-check-updates": "^21.0.1", "plantuml-encoder": "^1.4.0", "svgo": "^4.0.1", "vuepress": "^1.9.10", diff --git a/scripts/_render_svg.mjs b/scripts/_render_svg.mjs index 73a529b68..aa01947d5 100755 --- a/scripts/_render_svg.mjs +++ b/scripts/_render_svg.mjs @@ -32,7 +32,7 @@ async function main() { const rawPumlContents = fs.readFileSync(inputPath) const encoded = plantumlEncoder.encode(rawPumlContents.toString()) - const url = path.join(rendererBaseUrl, 'svg', encoded) + const url = `${rendererBaseUrl}/svg/${encoded}` let result try { result = await got.get(url) diff --git a/scripts/i18n-drift-check.mjs b/scripts/i18n-drift-check.mjs new file mode 100644 index 000000000..c074a220f --- /dev/null +++ b/scripts/i18n-drift-check.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { execSync } from "node:child_process"; + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i += 1) { + const current = argv[i]; + if (current.startsWith("--")) { + const key = current.slice(2); + const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : "true"; + args[key] = value; + if (value !== "true") i += 1; + } + } + return args; +} + +function readLocalesFromVuepressConfig(configPath) { + const content = fs.readFileSync(configPath, "utf8"); + const lastLocalesIndex = content.lastIndexOf("locales:"); + if (lastLocalesIndex === -1) return []; + + const objectStart = content.indexOf("{", lastLocalesIndex); + if (objectStart === -1) return []; + + let depth = 0; + let objectEnd = -1; + for (let i = objectStart; i < content.length; i += 1) { + const char = content[i]; + if (char === "{") depth += 1; + if (char === "}") depth -= 1; + if (depth === 0) { + objectEnd = i; + break; + } + } + + if (objectEnd === -1) return []; + + const localesBlock = content.slice(objectStart, objectEnd + 1); + const matches = [...localesBlock.matchAll(/["']\/([a-zA-Z0-9_-]+)\/["']\s*:/g)]; + const locales = [...new Set(matches.map((m) => m[1]).filter((value) => value && value !== "/"))]; + return locales.sort(); +} + +function runGit(command) { + return execSync(command, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +} + +function refExists(ref) { + if (!ref) return false; + try { + runGit(`git rev-parse --verify --quiet ${ref}^{commit}`); + return true; + } catch { + return false; + } +} + +function resolveBaseRef(requestedBase) { + const candidates = [ + requestedBase, + requestedBase?.startsWith("origin/") ? requestedBase.replace(/^origin\//, "") : null, + "origin/main", + "main", + "origin/master", + "master", + "HEAD~1", + "HEAD", + ].filter(Boolean); + + for (const candidate of candidates) { + if (refExists(candidate)) { + return candidate; + } + } + + return "HEAD"; +} + +function runGitDiff(baseRef, headRef) { + const command = `git diff --name-only ${baseRef}...${headRef}`; + const output = runGit(command); + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +function normalizePath(filePath) { + return filePath.split(path.sep).join("/"); +} + +function isSourceDoc(filePath, localeRoots) { + if (!filePath.startsWith("docs/")) return false; + if (!filePath.endsWith(".md")) return false; + if (filePath.startsWith("docs/.vuepress/")) return false; + return !localeRoots.some((locale) => filePath.startsWith(`docs/${locale}/`)); +} + +function toLocalizedPath(sourceFile, locale) { + const relativePath = sourceFile.slice("docs/".length); + return `docs/${locale}/${relativePath}`; +} + +function buildReport({ changedFiles, locales, repoRoot }) { + const changedSet = new Set(changedFiles.map(normalizePath)); + const sourceFiles = changedFiles.filter((file) => isSourceDoc(file, locales)); + + const impacted = sourceFiles.map((sourceFile) => { + const localeStatuses = locales.map((locale) => { + const localizedPath = toLocalizedPath(sourceFile, locale); + const absoluteLocalizedPath = path.join(repoRoot, localizedPath); + const exists = fs.existsSync(absoluteLocalizedPath); + + if (!exists) { + return { locale, path: localizedPath, status: "missing" }; + } + + if (!changedSet.has(localizedPath)) { + return { locale, path: localizedPath, status: "needs_review" }; + } + + return { locale, path: localizedPath, status: "updated" }; + }); + + return { source: sourceFile, locales: localeStatuses }; + }); + + const counts = { missing: 0, needs_review: 0, updated: 0 }; + for (const item of impacted) { + for (const localeInfo of item.locales) { + counts[localeInfo.status] += 1; + } + } + + return { + generatedAt: new Date().toISOString(), + changedFiles, + changedSourceFiles: sourceFiles, + locales, + impacted, + counts, + hasDrift: counts.missing > 0 || counts.needs_review > 0, + }; +} + +function buildMarkdownReport(report) { + if (report.changedSourceFiles.length === 0) { + return [ + "## Translation Drift Report", + "", + "No source markdown files changed in this diff range.", + ].join("\n"); + } + + const lines = [ + "## Translation Drift Report", + "", + `Detected locales: ${report.locales.map((locale) => `\`${locale}\``).join(", ") || "_none_"}`, + "", + `Source files changed: ${report.changedSourceFiles.length}`, + `- missing: ${report.counts.missing}`, + `- needs_review: ${report.counts.needs_review}`, + `- updated: ${report.counts.updated}`, + "", + ]; + + for (const item of report.impacted) { + lines.push(`### \`${item.source}\``); + for (const localeInfo of item.locales) { + const emoji = + localeInfo.status === "missing" + ? "❌" + : localeInfo.status === "needs_review" + ? "⚠️" + : "✅"; + lines.push(`- ${emoji} \`${localeInfo.locale}\`: \`${localeInfo.path}\` (${localeInfo.status})`); + } + lines.push(""); + } + + lines.push( + "> `needs_review` means translation exists but was not updated in the same diff range. Human review still required." + ); + + return lines.join("\n"); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const repoRoot = process.cwd(); + const configPath = path.join(repoRoot, "docs/.vuepress/config.js"); + + const requestedBaseRef = args.base || "origin/main"; + const headRef = args.head || "HEAD"; + const jsonOut = args["json-out"]; + const markdownOut = args["markdown-out"]; + const failOnMissing = args["fail-on-missing"] === "true"; + const baseRef = resolveBaseRef(requestedBaseRef); + + if (baseRef !== requestedBaseRef) { + process.stderr.write( + `[i18n-check] Base ref "${requestedBaseRef}" not found. Falling back to "${baseRef}".\n` + ); + } + + const locales = readLocalesFromVuepressConfig(configPath); + const changedFiles = runGitDiff(baseRef, headRef); + const report = buildReport({ changedFiles, locales, repoRoot }); + const markdown = buildMarkdownReport(report); + + if (jsonOut) { + fs.writeFileSync(jsonOut, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + } + + if (markdownOut) { + fs.writeFileSync(markdownOut, `${markdown}\n`, "utf8"); + } + + process.stdout.write(`${markdown}\n`); + + if (failOnMissing && report.counts.missing > 0) { + process.exit(2); + } +} + +main();