Compare commits

..

1 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 198eebaf4d Initial plan 2026-07-28 12:48:03 +00:00
70 changed files with 472 additions and 1491 deletions
@@ -1,6 +1,6 @@
---
name: shipping-misskey-change
description: Use at every finish moment of a Misskey change, before committing, opening a PR, merging, or handing work back, especially when proportional validation, SPDX, locale safety, migrations, or misskey-js generation may apply.
description: Use at every finish moment of a Misskey change, before committing, opening a PR, merging, or handing work back, especially when validation, SPDX, locale safety, migrations, misskey-js generation, or CHANGELOG checks may apply.
---
# shipping-misskey-change
+1 -1
View File
@@ -18,7 +18,7 @@
| `.claude/` 内のパス | 上流パス | 上流由来 | Misskey での改変 |
|---|---|---|---|
| `skills/context-budget/SKILL.md` | `skills/context-budget/SKILL.md` | ECC | description を日本語化、Misskey 固有メモを追記 |
| `commands/harness-audit.md` | `commands/harness-audit.md` | ECC | scripts 依存の自動採点を、repository-native command と SPDX checker で採点する版に書き換え。Misskey 固有の評価軸 (SPDX / endpoint-list / migration / locales) を組み込み |
| `commands/harness-audit.md` | `commands/harness-audit.md` | ECC | scripts 依存の自動採点を、Claude が `pnpm`/`git`/`grep` で手動採点する版に書き換え。Misskey 固有の評価軸 (SPDX / endpoint-list / migration / locales) を組み込み |
| `commands/quality-gate.md` | `commands/quality-gate.md` | ECC | 言語自動判定を排除し Misskey 固定 pipeline (`pnpm` + tsc + ESLint + Vitest) に。Prettier/Biome フェーズを削除 |
### MIT License (full text)
+1 -2
View File
@@ -10,8 +10,7 @@ frontmatter (`name` + `description` + `tools`) は、Claude が **自動でエ
レビュー面を増やしすぎないよう、役割を分ける:
- **この `.claude/agents/` の 2 つ**: backend endpoint / Vue SFC の **Misskey 固有・機械的チェック** (endpoint-list 登録漏れ・misskey-js 再生成漏れ・ja-JP.yml 限定・Storybook 併設 等)。
別コンテキストで差分を機械走査する価値がある領域に限定する
- **この `.claude/agents/` の 2 つ**: backend endpoint / Vue SFC の **Misskey 固有・機械的チェック** (endpoint-list 登録漏れ・misskey-js 再生成漏れ・ja-JP.yml 限定・SPDX 形式・Storybook 併設 等)。別コンテキストで差分を機械走査する価値がある領域に限定する
- **`pr-review-toolkit` プラグイン (code-reviewer / silent-failure-hunter 等)**: 言語非依存の一般的なコード品質・バグ・設計レビュー。Misskey 固有規約は見ない
- **`working-on-*` skill の checklist**: コードを **書いている最中** の自己チェック (レビュー専用ではなく実装ガイド)
+33 -9
View File
@@ -1,6 +1,6 @@
---
name: misskey-api-reviewer
description: Misskey backend の REST API エンドポイント (packages/backend/src/server/api/endpoints/) 追加・変更を機械レビューする。endpoint-list 登録漏れ・misskey-js 再生成漏れ・meta/paramDef/UUID を検査。backend API を変更した PR レビューで呼ぶ。
description: Misskey backend の REST API エンドポイント (packages/backend/src/server/api/endpoints/) 追加・変更を機械レビューする。endpoint-list 登録漏れ・misskey-js 再生成漏れ・meta/paramDef/UUID/SPDX を検査。backend API を変更した PR レビューで呼ぶ。
tools: Read, Grep, Glob, Bash
---
@@ -30,12 +30,26 @@ BASE=$(git merge-base origin/develop HEAD)
- `packages/backend/src/server/api/endpoint-list.ts`
- `packages/backend/test/e2e/**` (とくに `endpoints.ts``<area>.ts`)
- `packages/misskey-js/src/autogen/**`
- `CHANGELOG.md`
差分対象が空なら「レビュー対象の API エンドポイント変更なし」と短く報告して終了。
## チェックリスト
### 1. `meta` の必須・推奨フィールド (Major)
### 1. SPDX ヘッダー (Critical)
新規 `.ts` ファイル冒頭に以下があるか:
```
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
```
欠落すると CI の `spdx` ジョブが落ちる。
### 2. `meta` の必須・推奨フィールド (Major)
[endpoints.ts の型定義](../../packages/backend/src/server/api/endpoints.ts) を真とする。
@@ -49,7 +63,7 @@ BASE=$(git merge-base origin/develop HEAD)
- `res`: JSON Schema または `ref: '<EntityName>'`。各プロパティに `optional` / `nullable`**明示** されているか。
- `requireFile` / `secure` / `allowGet` / `cacheSec` / `description`: 該当するエンドポイントで使い分けているか。
### 2. `meta.errors` の UUID 検証 (Critical)
### 3. `meta.errors` の UUID 検証 (Critical)
`errors[*].id` が:
@@ -64,14 +78,14 @@ grep -rn "id: '<生成された UUID>'" packages/backend/src/server/api/endpoint
新規エンドポイントの全 `id` を抽出して衝突を確認する。
### 3. `paramDef` (Major)
### 4. `paramDef` (Major)
- JSON Schema 形式 (`type: 'object'`, `properties`, `required`)
- ID 文字列は `format: 'misskey:id'`
- `required` 配列で必須プロパティを明示
- `as const` または `as const satisfies Schema` で型推論を効かせる (既存実装は前者多数。`as const` 自体が無く `Schema` 型注釈もない場合のみ指摘)
### 4. エンドポイント実装本体 (Major)
### 5. エンドポイント実装本体 (Major)
- `Endpoint<typeof meta, typeof paramDef>` を継承しているか。
- `@Injectable()` デコレータ + `export default class` 形式か (`// eslint-disable-line import/no-default-export` が必要)。
@@ -80,7 +94,7 @@ grep -rn "id: '<生成された UUID>'" packages/backend/src/server/api/endpoint
- 防御的アサーション・「起きるはずがない」内部不整合・テスト用 ENV ガード等の **想定外フェイルファスト**`throw new Error('...')` で構わない。既存実装でも `admin/reset-password.ts` などが採用しているパターン (例: `cannot reset password of root`)。`meta.errors` に対応がない `throw new Error` を一律で指摘しない。
- 同期 `throw` は許容。非同期処理での例外伝搬を確認する。
### 5. ★ `endpoint-list.ts` への登録 (Critical)
### 6. ★ `endpoint-list.ts` への登録 (Critical)
最も忘れやすい。**忘れると 404**。[endpoint-list.ts](../../packages/backend/src/server/api/endpoint-list.ts) に 1 行追加されているか:
@@ -96,7 +110,7 @@ grep -F "'<category>/<name>'" packages/backend/src/server/api/endpoint-list.ts
**並び順の補足**: ファイル全体は厳密なアルファベット順では並んでおらず、同カテゴリ内 (`admin/queue/*` など) でも追加された経緯どおりの順になっている箇所が多い。**順序逸脱は指摘根拠にしない** (誤検知の元)。「行が存在するか」のみを Critical 観点として扱う。
### 6. `misskey-js` 再生成 (Critical)
### 7. `misskey-js` 再生成 (Critical)
`meta` / `paramDef` / `res` を変更したら、PR / ブランチに `packages/misskey-js/src/autogen/` 配下の差分が含まれているか確認する:
@@ -107,12 +121,22 @@ git diff --name-only "$BASE"...HEAD -- packages/misskey-js/src/autogen/
差分ゼロなら `pnpm build-misskey-js-with-types` の実行漏れ。CI の `check-misskey-js-autogen` ワークフローで必ず落ちるため Critical 扱い。
### 7. e2e テスト (Major)
### 8. e2e テスト (Major)
[test/e2e/endpoints.ts](../../packages/backend/test/e2e/endpoints.ts) または `test/e2e/<area>.ts` (`note.ts`, `users.ts` 等) 配下に、対応する `api('<category>/<name>', ...)` 呼び出しを含む `test(...)` ケースが追加されているか確認する。複雑な分岐 (権限チェック・エラーケース) の網羅も確認する。
**describe ラベルの形式は問わない**: 既存テストは `describe('Note', () => { test('投稿できる', ...) })` のように人間可読ラベルで構造化されており、`<category>/<name>` 形式の describe は使われていない。describe 名の規約違反としては指摘しない。
### 9. CHANGELOG エントリ (Minor)
ユーザー影響がある (新エンドポイント / 既存挙動変更) 場合、`CHANGELOG.md``## Unreleased``### Server` に 1 行追加されているか確認する。
```
- Feat: /api/<category>/<name> を追加
```
純粋な内部リファクタなら不要。
## 出力形式
優先度別に以下のフォーマットで出力する。
@@ -142,4 +166,4 @@ git diff --name-only "$BASE"...HEAD -- packages/misskey-js/src/autogen/
- [endpoint-base.ts (Endpoint 基底クラス)](../../packages/backend/src/server/api/endpoint-base.ts)
- [error.ts (ApiError)](../../packages/backend/src/server/api/error.ts)
- [test/e2e/endpoints.ts](../../packages/backend/test/e2e/endpoints.ts)
- [AGENTS.md](../../AGENTS.md) — 共通の安全規約と検証方針
- [AGENTS.md](../../AGENTS.md) — SPDX / マイグレーション履歴 / CHANGELOG 書式などの最低限ルール (Codex / Copilot と共通)
+38 -13
View File
@@ -1,6 +1,6 @@
---
name: vue-component-reviewer
description: Misskey frontend の Vue 3 SFC (packages/frontend/src/components/ / pages/ の *.vue) 変更を機械レビューする。Mk* 命名・i18n.ts/tsx・SCSS 変数・os.* 経由・a11y・Storybook 併設 (*.stories.impl.ts) を検査。frontend の .vue を変更した PR レビューで呼ぶ。
description: Misskey frontend の Vue 3 SFC (packages/frontend/src/components/ / pages/ の *.vue) 変更を機械レビューする。SPDX (HTML コメント)・Mk* 命名・i18n.ts/tsx・SCSS 変数・os.* 経由・a11y・Storybook 併設 (*.stories.impl.ts) を検査。frontend の .vue を変更した PR レビューで呼ぶ。
tools: Read, Grep, Glob, Bash
---
@@ -29,26 +29,40 @@ BASE=$(git merge-base origin/develop HEAD)
- `locales/*.yml` (とくに `ja-JP.yml` 以外の変更は即 Critical)
- `packages/frontend/src/components/**/*.stories.impl.ts`
- `CHANGELOG.md`
差分対象が空なら「レビュー対象の Vue コンポーネント変更なし」と短く報告して終了。
## チェックリスト
### 1. 命名規約 (Major)
### 1. SPDX ヘッダー (Critical)
`.vue` ファイル冒頭は **HTML コメント形式** で必須:
```html
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
```
`/* ... */` (TS 形式) は禁止 (CI の `spdx` ジョブはコメント形式ではなく SPDX 文字列の有無のみを検査するため、形式が違っても CI は通るが、規約違反として指摘する)。形式の根拠は references/knowledge 側を参照。
### 2. 命名規約 (Major)
- 共有 / 再利用コンポーネント (`packages/frontend/src/components/` 配下、サブディレクトリ含む) は `Mk` プレフィックス必須 (例: `MkButton.vue`, `global/MkAvatar.vue`, `grid/MkGrid.vue`)。
- ページ固有のものは `pages/` 配下に置き、`Mk` プレフィックスは不要。
**補足:** `<script setup>` SFC は named export を持たないため、「ファイル名と export 名の一致」を機械的に検査することはできない。SFC のデフォルトエクスポートはコンパイラ生成なので、ファイル名規約のみを基準にする。
### 2. `<script>` タグ (Major)
### 3. `<script>` タグ (Major)
- `<script lang="ts" setup>` または `<script setup lang="ts">` のどちらでもよい (既存コードは多数派が前者だが、後者も `MkThemePreview.vue` 等で使われている)。属性順は指摘しない。`lang="ts"`**無い** ものは指摘する。
- 型ジェネリックが必要なら `generic="T extends ..."` 属性を加える (順序問わず)。
- `defineProps<{ ... }>()` / `defineEmits<{ ... }>()`**type-only** 形式。runtime の object 形式 (`defineProps({ ... })`) は使わない。
- Options API (`export default { data() { ... } }`) は禁止。
### 3. i18n の使い分け (Critical)
### 4. i18n の使い分け (Critical)
- 文字列リテラルの直書き禁止 (テンプレート / JS 両方)。
- 引数なし: `i18n.ts.<path>` (例: `i18n.ts.deleted`)。
@@ -63,7 +77,7 @@ BASE=$(git merge-base origin/develop HEAD)
git diff --name-only "$BASE"...HEAD -- 'locales/*.yml' | grep -v 'ja-JP.yml'
```
### 4. スタイル (Major)
### 5. スタイル (Major)
- `<style lang="scss" module>` を既定とし、`:class="$style.foo"` で参照する。
- 新規で `<style scoped>` (module なし) は使わない (legacy)。
@@ -80,7 +94,7 @@ git diff "$BASE"...HEAD -- 'packages/frontend/src/**/*.vue' \
| grep -E '^\+' | grep -E '#[0-9a-fA-F]{3,8}\b|rgba?\('
```
### 5. UI 操作は `os.*` 経由 (Critical)
### 6. UI 操作は `os.*` 経由 (Critical)
- 直接の `alert()` / `confirm()` / `window.prompt()` / `window.alert()` は禁止。
- `os.alert` / `os.confirm` / `os.popup` / `os.toast` / `os.popupMenu` / `os.contextMenu` / `os.form` / `os.apiWithDialog` を使う ([os.ts](../../packages/frontend/src/os.ts) 参照)。
@@ -93,14 +107,14 @@ git diff "$BASE"...HEAD -- 'packages/frontend/src/**/*.vue' \
| grep -E '^\+' | grep -E '\b(alert|confirm|prompt)\s*\('
```
### 6. アクセシビリティ (Major)
### 7. アクセシビリティ (Major)
- クリック可能要素は `<button>` か、`role="button"` + `tabindex="0"` + キーボードハンドラ (`@keydown.enter` 等) を実装する。
- 装飾以外の `<div @click>` で a11y 配慮がないものは指摘する。
- フォーム要素には対応する `<label>` または `aria-label` を付ける。
- `:disabled` バインドや `aria-disabled` の整合性を確認する。
### 7. Storybook 併設 (Major)
### 8. Storybook 併設 (Major)
- 共有 `Mk*` コンポーネントを新規追加した場合、`Mk<Name>.stories.impl.ts` が同階層に併設されているか (サブディレクトリ含む。例: `components/global/MkAvatar.stories.impl.ts`, `components/grid/MkGrid.stories.impl.ts`)。
- **ファイル名は `.stories.impl.ts` 固定** (`.stories.ts` は生成物なので手編集・コミット不可)。
@@ -116,20 +130,31 @@ git diff --name-only --diff-filter=A "$BASE"...HEAD -- \
| xargs -I {} sh -c 'test -f {} || echo "missing: {}"'
```
### 8. アイコン (Minor)
### 9. アイコン (Minor)
- アイコンは Tabler icons クラス (`<i class="ti ti-info-circle">` 等) を使う。
- インライン SVG や別アイコンセットは原則使わない (既存パターンに合わせる)。
### 10. CHANGELOG エントリ (Minor)
ユーザー影響がある変更なら、`CHANGELOG.md``## Unreleased``### Client` に 1 行追加されているか確認する。
```
- Enhance: <component> の <挙動> を改善
- Fix: <component> の <不具合> を修正
```
純粋な内部リファクタなら不要。
## 出力形式
優先度別に以下のフォーマットで出力する。
```
## 🔴 Critical
- packages/frontend/src/components/MkFoo.vue:42
ブラウザ組み込みの `alert()` を直接呼んでいる。
`os.alert()` を使用すること。
- packages/frontend/src/components/MkFoo.vue:1
SPDX ヘッダーが HTML コメント形式ではなく TS 形式になっている。
`<!-- ... -->` で書き直すこと。
## 🟡 Major
- ...
@@ -150,4 +175,4 @@ git diff --name-only --diff-filter=A "$BASE"...HEAD -- \
- [MkButton.vue](../../packages/frontend/src/components/MkButton.vue)
- [MkInput.vue](../../packages/frontend/src/components/MkInput.vue) — generic SFC 例
- [MkButton.stories.impl.ts](../../packages/frontend/src/components/MkButton.stories.impl.ts) — Storybook 雛形
- [AGENTS.md](../../AGENTS.md) — 共通の安全規約と検証方針
- [AGENTS.md](../../AGENTS.md) — SPDX / locales 編集制限 / CHANGELOG 書式などの最低限ルール (Codex / Copilot と共通)
+21 -13
View File
@@ -12,9 +12,7 @@ upstream path: commands/harness-audit.md
upstream license: MIT — https://github.com/affaan-m/everything-claude-code/blob/main/LICENSE
project-level notice: see .claude/THIRD_PARTY_LICENSES.md (Misskey 内サードパーティ一覧 + MIT 全文)
Imported into Misskey .claude/ on 2026-05-10.
The 7-category rubric and output contract are derived from the upstream ECC version (MIT).
The runtime layer was substantially reimplemented for Misskey: the upstream relies on scripts/harness-audit.js to mechanically score, while this version uses repository-native commands and the SPDX checker, and adds Misskey-specific evaluation axes (SPDX coverage / endpoint-list 登録漏れ / migration 順序 / ja-JP.yml 整合).
Imported into Misskey .claude/ on 2026-05-10. The 7-category rubric and output contract are derived from the upstream ECC version (MIT). The runtime layer was substantially reimplemented for Misskey: the upstream relies on scripts/harness-audit.js to mechanically score, while this version asks Claude to score directly with pnpm/git/grep, and adds Misskey-specific evaluation axes (SPDX coverage / endpoint-list 登録漏れ / migration 順序 / ja-JP.yml 整合).
note: 元 ECC 版は scripts/harness-audit.js (専用 Node スクリプト) で機械採点していたが、Misskey は ECC plugin runtime に依存しない方針なので、Claude が直接ファイルを読んで採点する手動運用版に書き換えた。Misskey 固有の重要観点 (SPDX 適用率 / endpoint-list 登録漏れ / migration 順序 / ja-JP.yml 整合) を評価軸として明示的に組み込んでいる。
-->
@@ -35,10 +33,10 @@ Misskey リポジトリの `.claude/` 構成を 7 カテゴリで採点し、改
| --- | --- | --- |
| 1 | Tool Coverage | skill / agent / command の数、欠けているワークフロー段、重複なし |
| 2 | Context Efficiency | frontmatter description の冗長度、SKILL.md の長さ分布、重複情報、CLAUDE.md の肥大化 |
| 3 | Quality Gates | 変更ファイル lint、`/quality-gate`、変更別 test / typecheck、CI gate との整合 |
| 3 | Quality Gates | Stop / PreToolUse / PostToolUse hook の整備、`/quality-gate` 等の完了前ゲートの有無、自動 lint/typecheck |
| 4 | Memory Persistence | `.claude/skills/*/SKILL.md``references/` の同期状態を評価。プロジェクト側 `.claude/memory/` は未採用方針 (auto-memory はユーザーホーム側で自動運用) のため、ここを採点起点にせず既定 5/10 から開始する |
| 5 | Eval Coverage | `working-on-backend` / `working-on-frontend` の testing リファレンス (backend-testing.md / frontend-testing.md) の網羅、Misskey 固有の e2e/fed/Storybook/Playwright 適用ガイド |
| 6 | Security Guardrails | SPDX checker、migration 不変性ルール、ja-JP.yml 限定編集ルール、secrets 検出 |
| 6 | Security Guardrails | SPDX 規約適用、migration 不変性ルール、ja-JP.yml 限定編集ルール、secrets 検出 |
| 7 | Cost Efficiency | enabledPlugins の重複・過剰、context-budget の整備、MCP 過剰登録なし |
## Misskey 固有の確認項目 (採点根拠コマンド)
@@ -46,9 +44,18 @@ Misskey リポジトリの `.claude/` 構成を 7 カテゴリで採点し、改
採点時に以下を実コマンドで確認する。各項目の **属するカテゴリ** は項目内に明記する (#1-#3 は Security Guardrails、#4 は Tool Coverage、#5 は Quality Gates):
```bash
# 1. [Security Guardrails] SPDX 対象・CI 判定・HTML コメント形式
node scripts/check-spdx.mjs
# → exit 0 (`SPDX: OK`) なら満点。追加の目視判定はしない
# 1. [Security Guardrails] SPDX 適用率 (新規ファイル想定の汎用チェック)
# - node_modules を prune で除外
# - packages/misskey-js は MIT サブパッケージなので AGPL ヘッダーを持たない (AGENTS.md §1) → 除外
# - built/ なども除外
# 候補にはなお *.config.{ts,js} / *eslint* / *.d.ts のような CI 上 SPDX 対象外
# (.github/workflows/check-spdx-license-id.yml の exclude 参照) も混ざるため、
# 上位に出たファイルが「新規追加した実コード」かどうかは目視判定する。
find packages \
\( -type d \( -name node_modules -o -name built -o -name dist -o -path 'packages/misskey-js' \) -prune \) \
-o -type f \( -name '*.ts' -o -name '*.js' -o -name '*.vue' -o -name '*.scss' \) -print \
| xargs -r grep -L 'SPDX-License-Identifier: AGPL-3.0-only' | head -20
# → 上位に新規実コードが無ければ満点
# 2. [Security Guardrails] ja-JP.yml 以外の locales が直近で手動編集されていないか
# --pretty=format: でコミットヘッダ行を抑止し、ファイル名行のみを残してから grep する。
@@ -98,19 +105,20 @@ grep -rn 'console\.\(log\|debug\)' packages/backend/src packages/frontend/src 2>
## サンプル出力
```text
Harness Audit (repo): 52/70
Harness Audit (repo): 55/70
Tool Coverage: 9/10 (skills 5, agents 2, commands 5 — 偏りなし)
Context Efficiency: 8/10 (description 平均 3-5 行、肥大なし)
Quality Gates: 7/10 (変更ファイル lint / `/quality-gate` あり、広域 gate は任意)
Quality Gates: 5/10 (Stop hook 共有設定に未登録 / `/quality-gate` あり)
Memory Persistence: 5/10 (プロジェクト側 memory/ 未採用方針 = 既定値)
Eval Coverage: 7/10 (backend/frontend testing リファレンス網羅、Storybook 一部抜け)
Security Guardrails: 8/10 (SPDX 欠落 1 件、locales OKmigrations clean)
Security Guardrails: 10/10 (SPDX 100%, locales OK, migrations clean)
Cost Efficiency: 8/10 (context-budget 導入済 / MCP 0)
Failed Checks:
- packages/frontend/src/.../X.vue で SPDX 欠落 (Security Guardrails)
- console.log が backend に 3 件 (Quality Gates)
- 共有 Stop hook なし (Quality Gates) — 各 contributor が `.claude/settings.local.json` で opt-in する方針なら減点しなくて良い
Top 3 Actions:
1) [Security Guardrails] SPDX 欠落 1 ファイルを修正:
@@ -129,10 +137,10 @@ Suggested next skills to apply:
- 確定的: 同じ commit / 同じ `.claude/` 構成なら同じスコア
- ヒューリスティクス: 「description の冗長度」のような主観項目は同一基準で機械的に判定
- 専用 audit script 不要: repository の `pnpm` / `git` コマンドと SPDX checker を使う
- スクリプト不要: `pnpm` `git``grep`/`find` 等の標準ツールのみ
## 参考: ECC オリジナルとの差分
- ECC 版は `node scripts/harness-audit.js` を直叩きする運用で、ECC リポジトリ全体に閉じた採点だった。
- Misskey 版は **Misskey の規約 (SPDX/migration/locales/endpoint-list)** を Security 採点に組み込み、repository-native command で根拠を取る方式に再設計。
- Misskey 版は **Misskey の規約 (SPDX/migration/locales/endpoint-list)** を Security 採点に組み込み、`pnpm` ベースの実コマンドで根拠を取る方式に再設計。
- 結果として ECC への依存はゼロ。
+37 -32
View File
@@ -1,5 +1,5 @@
---
description: Misskey の lint / typecheck / 高速テストを順に実行する任意の広域品質検証。
description: Misskey の lint / typecheck / 高速テストを順に実行して品質ゲートを通すコマンド。完了前の軽量検証
argument-hint: "[repo|backend|frontend|<path/to/file.ts>]"
---
@@ -14,35 +14,33 @@ project-level notice: see .claude/THIRD_PARTY_LICENSES.md (Misskey 内サード
Imported into Misskey .claude/ on 2026-05-10. Pipeline 概念 (lint → typecheck → test) は upstream ECC 版から借用 (MIT)。実コマンド層は Misskey の pnpm + tsc + ESLint + Vitest に固定し、formatter (Prettier/Biome) フェーズは削除した。
note: 元 ECC 版は言語自動判定 + format/lint/type のジェネリック版だったが、Misskey 専用に pnpm + tsc + ESLint + Vitest の組み合わせに固定。
重い test:e2e / test:fed は含めず、変更内容または明示依頼に応じて個別実行する。
note: 元 ECC 版は言語自動判定 + format/lint/type のジェネリック版だったが、Misskey 専用に pnpm + tsc + ESLint + Vitest の組み合わせに固定。重い test:e2e / test:fed は含まない (CI 側で実行される)。
-->
# /quality-gate — Misskey 広域品質検証
# /quality-gate — Misskey 軽量品質ゲート
`/quality-gate [scope]`
package または repo 全体の状態が必要なときに任意で使う
完了時に必須の変更ファイル lint は [shipping-misskey-change](../skills/shipping-misskey-change/SKILL.md) が担当する。
完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Playwright) は CI 側で実行されるため、本コマンドには含めない
## Scope
- `repo` (default) — 全 workspace の lint + backend / frontend の unit test
- `repo` (default) — 全パッケージ
- `backend``packages/backend` のみ
- `frontend``packages/frontend` のみ
- `path/to/file.ts` — 単一ファイルへの ESLint `--quiet` のみ
- `path/to/file.ts` — 単一ファイルへの ESLint --fix のみ
## Pipeline
### Repo scope
### Repo scope (全部)
各パッケージの `lint` スクリプト実体は `pnpm typecheck && pnpm eslint` ([packages/backend/package.json](../../packages/backend/package.json), [packages/frontend/package.json](../../packages/frontend/package.json))
ルートの `pnpm lint``pnpm --no-bail -r lint && pnpm check-dts` なので、そのまま実行すると workspace lint の失敗時に `check-dts` が実行されない。
次の 4 コマンドをそれぞれ独立した Bash 呼び出しとして実行し、先の失敗にかかわらず全結果を収集する:
各パッケージの `lint` スクリプト実体は `pnpm typecheck && pnpm eslint` ([packages/backend/package.json](../../packages/backend/package.json), [packages/frontend/package.json](../../packages/frontend/package.json)) で、ルートの `pnpm lint``pnpm --no-bail -r lint` (= 全パッケージで lint を `--no-bail` で実行)。**typecheck は lint に含まれている**ため、通常はこの 2 コマンドで十分:
```bash
pnpm --no-bail -r lint
pnpm check-dts
# 1. Lint (= typecheck + ESLint、全パッケージ。--no-bail で最初の失敗で止まらず全結果を集める)
pnpm lint
# 2. Unit test (高速、e2e は含まない)
pnpm --filter backend test
pnpm --filter frontend test
```
@@ -58,8 +56,7 @@ pnpm --filter frontend typecheck # vue-tsc 単体 (Vue SFC の型を見るた
### Backend scope
`pnpm --filter backend lint` は内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/backend/package.json](../../packages/backend/package.json)) ので、`lint` を回せば typecheck も終わる。
広域検証では typecheck の二重実行を避けるため `lint` + `test` のみ:
`pnpm --filter backend lint` は内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/backend/package.json](../../packages/backend/package.json)) ので、`lint` を回せば typecheck も終わる。軽量ゲートでは typecheck の二重実行を避けるため `lint` + `test` のみ:
```bash
pnpm --filter backend lint
@@ -70,7 +67,7 @@ pnpm --filter backend test
### Frontend scope
`pnpm --filter frontend lint` も内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/frontend/package.json](../../packages/frontend/package.json)) ため、広域検証では Backend 同様に `lint` + `test` のみ:
`pnpm --filter frontend lint` も内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/frontend/package.json](../../packages/frontend/package.json)) ため、軽量ゲートでは Backend 同様に `lint` + `test` のみ:
```bash
pnpm --filter frontend lint
@@ -81,38 +78,46 @@ pnpm --filter frontend test
### Single file scope
repo-relative path を package-relative path に変換し、該当 package root で実行する。
```bash
(cd packages/backend && pnpm exec eslint --quiet -- src/path/to/file.ts)
(cd packages/frontend && pnpm exec eslint --quiet -- src/path/to/component.vue)
pnpm exec eslint --fix <path>
```
## Output
各コマンドの終了コードを保持し、実行項目を `PASS / FAIL / BASELINE / SKIPPED` で集計する。
`BASELINE` は同じ失敗が base 側でも再現し、今回の変更と無関係と確認できた場合だけ使う。
実行したフェーズの pass/fail と件数を集計する。標準パイプラインは `pnpm lint` (typecheck 内包) と unit test のみなので、デフォルトの出力は以下のようになる:
```text
Quality Gate (repo):
Lint: PASS
Backend ut: BASELINE (base 側でも同じ既存失敗)
Frontend ut: PASS
Other tests: SKIPPED (repo scope の対象外)
Lint: PASS (0 errors, 2 warnings)
Backend ut: PASS (412/412)
Frontend ut: PASS (87/87)
→ 完了前の軽量チェック OK。重い e2e / 連合テストは CI 側で実行される。
```
一つが失敗しても独立した残りの検証は続ける。
`BASELINE``PASS` と表示せず、未実行の項目は理由とともに `SKIPPED` とする。
`#### 詳細を分けて見たい時のみ (optional)` で個別 typecheck (`pnpm --filter backend typecheck` / `pnpm --filter frontend typecheck`) も回した場合のみ、その結果を追加行として表示する:
```text
Quality Gate (repo):
Lint: PASS (0 errors, 2 warnings)
Backend tc: PASS (0 errors) # optional 実行時のみ
Frontend tc: PASS (0 errors) # optional 実行時のみ
Backend ut: PASS (412/412)
Frontend ut: PASS (87/87)
```
失敗時は最初に落ちたフェーズで停止して詳細を見せる。
## 関連 skill / コマンド
- [`shipping-misskey-change` スキル](../skills/shipping-misskey-change/SKILL.md) — commit / PR 直前の最終チェックリスト
- [`shipping-misskey-change` スキル](../skills/shipping-misskey-change/SKILL.md) — commit / PR 直前の最終チェックリスト (misskey-js 再生成 / SPDX / CHANGELOG 等)
- [`shipping-misskey-change/references/tasks/regenerate-misskey-js.md`](../skills/shipping-misskey-change/references/tasks/regenerate-misskey-js.md) — API 変更時の `pnpm build-misskey-js-with-types` 実行手順
- [.github/copilot-instructions.md §Validation コマンド](../../.github/copilot-instructions.md) — pnpm コマンド一覧 (Copilot / Codex 向けに再掲)
## 元 ECC 版との差分
- ジェネリックな言語自動判定を排除し、Misskey 固定 pipeline に。
- formatter フェーズなし (変更ファイル lint は ESLint `--quiet`)。
- e2e / federation / Playwright は scope に自動追加せず、変更内容または明示依頼に応じて個別実行
- formatter フェーズなし (Misskey は ESLint --fix のみ採用)。
- e2e / federation / Playwright は重いため除外し CI 側に委譲
+19 -39
View File
@@ -1,53 +1,33 @@
---
name: shipping-misskey-change
description: Use at every finish moment of a Misskey change, before committing, opening a PR, merging, or handing work back. Selects proportional validation, runs changed-file lint and repository safety checks, and records PASS/FAIL/BASELINE/SKIPPED without chasing unrelated failures.
description: Use at every "finish" moment of a Misskey change — immediately before committing, opening a PR, merging, or handing the work back to the user even without a commit. Runs the final pre-ship checklist — `pnpm lint`, misskey-js regeneration (`pnpm build-misskey-js-with-types`) when backend API changed, `pnpm --filter backend check-migrations` when entities or migrations changed, SPDX header verification on new files, locale safety check (no edits to non-`ja-JP` locale yml files), and `CHANGELOG.md` Unreleased entry for user-visible changes. Must be consulted as the last step of every change — including uncommitted handoffs — to avoid CI failures and lost translations. This is NOT waived by having already invoked brainstorming, writing-plans, or any other upstream skill — invoke this regardless of what preceded it.
---
# shipping-misskey-change
Misskey の変更commit / PR / merge する直前、または未commitでユーザーへ返す直前の出口
規範は [AGENTS.md](../../../AGENTS.md)、ここでは実行方法だけを定める。
Misskey の変更**finish 局面** (commit / PR / merge する直前、またはコミットせずユーザーに作業を返す直前) に必ず走らせる最終チェックリスト
## 1. 検証レベル
CI で落ちやすい / レビュアーから指摘されやすいポイントを 1 箇所に集めている。後で references を辿る余裕を作らないため、チェックリストは SKILL.md 本体に直書きする。
| 段 | 条件 | 実行 |
| --- | --- | --- |
| 1 (必須) | package の ESLint 対象ファイルを変更 | 存在する変更ファイルへ `eslint --quiet` を最後に 1 回 |
| 2 | 実装・挙動を変更 | 最も近い unit test を実行。型・生成物・DB に関係するときは対応する専用検証も実行 |
| 3 (任意) | 明示依頼、広域変更、切り分けに必要 | package / repo 全体の lint、build、広域 test |
**他スキル実行後も免除されない。** `brainstorming` / `writing-plans` / その他アップストリームスキルを先に呼んでいても、作業を返す直前・commit 直前のタイミングでこのスキルを呼ぶこと。
段 1 は docs-only など対象が空なら `SKIPPED`
段 3 の既存失敗は成功扱いせず `BASELINE` として、今回の変更との関係だけを報告する。
## 最終チェックリスト
### 自動検査
このリストを TodoWrite に展開して 1 項目ずつ確認すること。**該当しない項目は飛ばして良いが、判断は明示する**。
repo root で次を 1 回実行する。
- [ ] lint が通る — ECC 由来の [/quality-gate](../../commands/quality-gate.md) コマンドで lint (typecheck + eslint) + 高速テストをまとめて回すのが基本。lint だけ単発で確認したいなら `pnpm lint` 直接でもよい
- [ ] backend で `meta` / `paramDef` / `res` を変更した → `pnpm build-misskey-js-with-types` を実行して `packages/misskey-js/src/autogen/` の差分も commit に含めた → 詳細手順は [references/tasks/regenerate-misskey-js.md](references/tasks/regenerate-misskey-js.md)
- [ ] エンティティ (`packages/backend/src/models/*.ts``@Column` / `@Entity` / `@Index`) を変更した → `pnpm --filter backend check-migrations` が pending DDL 0 件で通る
- [ ] migration ファイルを追加した → `up()``down()` の両方を実装した / 既存のマージ済 migration は一切触っていない
- [ ] 新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.vue` / `.scss` / `.html` ファイルを追加した → SPDX ヘッダーを付けた (`.vue` / `.html` は HTML コメント形式、その他は TS コメント形式)
- [ ] `locales/` を編集した → **`ja-JP.yml` だけ** を変更しており、他言語 yml の diff は出ていない (`git diff --name-only develop -- 'locales/*.yml' | grep -v '^locales/ja-JP\.yml$'` が空)
- [ ] ユーザーから見える変更 (機能追加 / 既存挙動変更) → `CHANGELOG.md``## Unreleased` 直下の該当サブセクション (General / Client / Server) に 1 行追記した → 詳細書式は [references/tasks/changelog-update.md](references/tasks/changelog-update.md)
- [ ] backend API endpoint を追加・変更した → [misskey-api-reviewer](../../agents/misskey-api-reviewer.md) agent を Task で起動して機械レビューする (endpoint-list 登録漏れ / misskey-js 再生成漏れ / meta・UUID / SPDX。lint や CI では拾いにくい 404・登録漏れの最終関門なので、該当する変更があれば飛ばさない)
- [ ] frontend の `.vue` を追加・変更した → [vue-component-reviewer](../../agents/vue-component-reviewer.md) agent を Task で起動して機械レビューする (SPDX 形式 / 命名 / i18n / SCSS 変数 / os.* / a11y / Storybook 併設)
- [ ] (任意) `.claude/` ハーネス自体の健全性を確認したい → ECC 由来の [/harness-audit](../../commands/harness-audit.md) コマンドを実行
```bash
node scripts/check-shipping.mjs
```
## 何のためのスキルか
統合先を明示する場合は `--base <ref>`、または `MISSKEY_BASE_REF` を使う
script は次を行い、独立した検査を最後まで続けて exit 0 (合格) / 1 (違反) / 2 (検査不能) に集約する。
これは「**作業中に何を作るか**」を決めるスキルではなく、「**作り終わった後に CI を通す**」スキル。`working-on-backend` / `working-on-frontend` から始まった作業の **出口** として機能する
- commit 済み・未commit・untracked の変更集合を NUL-safe に列挙し、変更ファイルだけへ package root から `eslint --quiet` を実行
- SPDX 違反時はローカル変更の欠落だけ `check-spdx.mjs --fix` で補い、通常検査を再実行
- SPDX の結果にかかわらず、`locales/ja-JP.yml` 以外の locale YAML 変更を検査
`SPDX: OK` 後は追加確認しない。
その他の常設方針は AGENTS.md に従う。
## 2. 変更別チェック
- backend API の `meta` / `paramDef` / `res`: `pnpm build-misskey-js-with-types`
手順は [regenerate-misskey-js.md](references/tasks/regenerate-misskey-js.md)
- entity / migration: `pnpm --filter backend check-migrations`
新規 migration は `up()` / `down()`、既存のマージ済 migration は差分なし
- backend API endpoint: [misskey-api-reviewer](../../agents/misskey-api-reviewer.md) を実行
- frontend `.vue`: [vue-component-reviewer](../../agents/vue-component-reviewer.md) を実行
## 3. 引き継ぎ
実行項目を `PASS / FAIL / BASELINE / SKIPPED` で短く列挙する。
失敗時は今回の変更との関係、未実行時は理由を書く。
ユーザーが依頼していない commit / PR / 外部送信は行わない。
該当する変更がある場合は各 references/tasks/ を Read して詳細手順を踏むこと。`pnpm lint` だけは references を読まずに直接走らせて良い (`/quality-gate` でまとめて回せる)。
@@ -1,7 +1,6 @@
# 明示依頼時に CHANGELOG.md の Unreleased セクションを更新する
# CHANGELOG.md の Unreleased セクションに 1 行追記する
ユーザーから CHANGELOG 編集を明示的に依頼された場合だけ読む書式リファレンス
通常の実装・修正では編集しない。
ユーザー影響のある変更 (機能追加・修正・改善) は `CHANGELOG.md` の冒頭 `## Unreleased` セクションに 1 行追加する。リファクタリング等の内部変更は不要
## セクション構造
+1 -1
View File
@@ -30,6 +30,6 @@ SKILL.md 本体は references への索引だけ。具体的な手順や規約
## 必ず最後に通る場所
backend の変更を commit / PR にする前に、必ず [shipping-misskey-change](../shipping-misskey-change/SKILL.md) の最終チェックリストに従う。
backend の変更を commit / PR にする前に、必ず [shipping-misskey-change](../shipping-misskey-change/SKILL.md) の最終チェックリストに従う。`pnpm lint` / misskey-js 再生成 / `check-migrations` / SPDX / CHANGELOG をまとめて確認する。
API endpoint を追加・変更したなら、その出口で [misskey-api-reviewer](../../agents/misskey-api-reviewer.md) agent (この skill の規約を review-mode から機械チェックする専門 reviewer) を Task で起動すると、endpoint-list 登録漏れや misskey-js 再生成漏れを取りこぼしにくい。
@@ -15,6 +15,7 @@
2. 実装 : meta / paramDef / クラス本体を書く (SPDX ヘッダー付き)
3. 配線 : endpoint-list.ts に登録 (★ 忘れると 404)
4. 検証 : e2e テスト + lint + misskey-js 再生成
5. 仕上げ : CHANGELOG エントリ (shipping-misskey-change で確認)
```
---
@@ -253,6 +254,12 @@ PR に `packages/misskey-js/src/autogen/` 配下の差分が含まれていな
---
## 5. 仕上げフェーズ — CHANGELOG
ユーザー影響がある (新機能 / 既存挙動変更) なら `CHANGELOG.md``## Unreleased``### Server` に 1 行追加する。詳細は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) に従う。
---
## 落とし穴サマリ (PR で頻発するミス)
詳細な症状 → 原因 → 修正 のフォーマット → **[knowledge/api-meta-paramdef.md](../knowledge/api-meta-paramdef.md) §落とし穴**
@@ -161,6 +161,12 @@ pnpm migrate
---
## CHANGELOG (ユーザー影響がある場合)
スキーマ変更がユーザーに見える挙動を生む場合のみ、`CHANGELOG.md` に追記する。内部リファクタや純粋なインデックス追加は不要。詳細は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) で確認。
---
## 提出前セルフレビューチェックリスト
完了前に以下を上から確認する (各項目を TodoWrite 化してよい):
@@ -171,3 +177,4 @@ pnpm migrate
- [ ] `up()` の各文に対応する巻き戻しが `down()` にあり、**`down()` が空でない** (難ケースは [knowledge/typeorm-patterns.md](../knowledge/typeorm-patterns.md) を確認済み)
- [ ] `pnpm --filter backend check-migrations` が **0 件 (pending DDL なし)** で通る
- [ ] (可能なら) `pnpm migrate` → `pnpm revert` → `pnpm migrate` が通る
- [ ] ユーザーに見える変更なら CHANGELOG 追記 → [shipping-misskey-change](../../../shipping-misskey-change/SKILL.md)
+3 -3
View File
@@ -1,6 +1,6 @@
---
name: working-on-frontend
description: Use whenever editing or adding code under `packages/frontend/`, or editing `locales/ja-JP.yml` for frontend-facing UI text — including Vue 3 SFCs (`Mk*` components), i18n keys (`i18n.ts.<key>` / `i18n.tsx.<key>()`), SCSS Modules, theme/CSS variables, `os.*` UI helpers, and Storybook stories. Covers `<script setup lang="ts">` conventions, type-only defineProps, `ja-JP.yml`-only locale editing (other locale yml files are Crowdin-managed and must not be edited), and accessibility. Must be consulted before any frontend or UI-locale change to avoid CI failures, lost translations, and reviewer pushback. This is NOT waived by having already invoked brainstorming, writing-plans, or any other upstream skill — invoke this at implementation time regardless of what preceded it.
description: Use whenever editing or adding code under `packages/frontend/`, or editing `locales/ja-JP.yml` for frontend-facing UI text — including Vue 3 SFCs (`Mk*` components), i18n keys (`i18n.ts.<key>` / `i18n.tsx.<key>()`), SCSS Modules, theme/CSS variables, `os.*` UI helpers, and Storybook stories. Covers SPDX (HTML comment form), `<script setup lang="ts">` conventions, type-only defineProps, `ja-JP.yml`-only locale editing (other locale yml files are Crowdin-managed and must not be edited), and accessibility. Must be consulted before any frontend or UI-locale change to avoid CI failures, lost translations, and reviewer pushback. This is NOT waived by having already invoked brainstorming, writing-plans, or any other upstream skill — invoke this at implementation time regardless of what preceded it.
---
# working-on-frontend
@@ -31,6 +31,6 @@ SKILL.md 本体は references への索引だけ。具体的な手順や規約
## 必ず最後に通る場所
frontend の変更を commit / PR にする前に、必ず [shipping-misskey-change](../shipping-misskey-change/SKILL.md) の最終チェックリストに従う。
frontend の変更を commit / PR にする前に、必ず [shipping-misskey-change](../shipping-misskey-change/SKILL.md) の最終チェックリストに従う。`pnpm lint` / SPDX / `ja-JP.yml` のみ編集確認 / CHANGELOG をまとめて確認する。
`.vue` を追加・変更したなら、その出口で [vue-component-reviewer](../../agents/vue-component-reviewer.md) agent (この skill の規約を review-mode から機械チェックする専門 reviewer) を Task で起動すると、命名・i18n・SCSS 変数・a11y・Storybook 併設の逸脱を取りこぼしにくい。
`.vue` を追加・変更したなら、その出口で [vue-component-reviewer](../../agents/vue-component-reviewer.md) agent (この skill の規約を review-mode から機械チェックする専門 reviewer) を Task で起動すると、SPDX 形式・命名・i18n・SCSS 変数・a11y・Storybook 併設の逸脱を取りこぼしにくい。
@@ -227,6 +227,18 @@ git checkout HEAD -- locales/zh-CN.yml
PR 化前なら何度でもやり直せる。**マージしてしまうと Crowdin 側との整合性が崩れて手動回復が必要** になるので、PR レビュー段階で必ず `locales/*.yml` (ja-JP 以外) の diff がゼロであることを確認する。
### CHANGELOG 記載の判定
| 変更内容 | CHANGELOG 記載 |
|---|---|
| 新規画面追加と一緒に新キー追加 | 必要 (`### Client` に Feat/Enhance) |
| 既存文言の改善 (誤字脱字以外) | 必要 (`### Client` に Enhance) |
| 誤字脱字・微妙な言い回し修正 | 不要 |
| キーのリネーム (UI 変化なし) | 不要 |
| キー削除 (画面から消える) | 必要 (`### Client` に Feat / 機能削除) |
書き方は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) を参照。
## トラブルシュート
i18n 周辺で踏みやすい失敗とその対処。エラー文字列で grep してたどり着けるよう整理。
@@ -88,6 +88,8 @@ git diff --name-only develop -- 'locales/*.yml' | grep -v '^locales/ja-JP\.yml$'
**注意:** `grep -v 'ja-JP.yml'`**diff 本文** に当てると ja-JP.yml 単体の変更でも `+追加行` が素通りして必ず非空になる。`--name-only` でファイル名だけに絞ってから完全一致で除外するのが正しい。
ユーザー影響のある UI 変更を伴う場合は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) で CHANGELOG エントリの判定をする。
## 例: 「ノートを削除しますか?」確認ダイアログを追加する
1. `locales/ja-JP.yml`:
@@ -174,6 +174,10 @@ pnpm --filter frontend storybook-dev # localhost:6006
pnpm --filter frontend test
```
## CHANGELOG エントリ
ユーザーから見える変更 (新規コンポーネントが新しい UI として露出する、既存 UI の挙動を変える) なら、`CHANGELOG.md` に追記する。判定方法と書式は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) で確認。
## 既存コンポーネントとの整合性
- 似た用途の既存 `Mk*` を 1-2 個読んで、props 命名 (`primary` / `danger` / `small` 等の形容詞、`onClose` ではなく `emit('close')` 等) を揃える
+6 -11
View File
@@ -10,8 +10,7 @@
### コード・データ関連
- **SPDX ヘッダー必須**: AGPL-3.0-only 管轄かつ SPDX CI 対象ディレクトリに新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.scss` / `.vue` / `.html` ファイルを追加する場合は冒頭に必ず付ける。
対象判定は `scripts/check-spdx.mjs` を参照。
- **SPDX ヘッダー必須**: AGPL-3.0-only 管轄かつ SPDX CI 対象ディレクトリに新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.scss` / `.vue` / `.html` ファイルを追加する場合は冒頭に必ず付ける。詳細な対象判定は `.github/workflows/check-spdx-license-id.yml` を参照。
```text
/*
@@ -30,8 +29,6 @@
```
`packages/misskey-js` は MIT ライセンスのサブパッケージなので、この AGPL ヘッダーを一律に付けない (サブパッケージ固有の `package.json` / `LICENSE` / 既存ファイルのヘッダーに従う)。
SPDX の合否は CI と skill が同じ `scripts/check-spdx.mjs` で判定するため、code review で目視チェックを重ねない。
CI は `--ci` で SPDX 行の有無を検査し、既定モードは加えて `.vue` / `.html` のコメント形式を検査する。
- **`locales/ja-JP.yml` 以外の locale YAML を編集しない**。他言語ファイル (`en-US.yml` など `ja-JP.yml` 以外すべて) は Crowdin の自動配信先で、手動編集すると次の同期で上書き喪失する。
- **マージ済 migration を編集しない**。`packages/backend/migration/{timestamp}-*.js` のうち既に `develop` / `master` に入ったものは絶対に変更しない。スキーマ変更が必要なら新しい timestamp で新規ファイルを追加し、`up()` と `down()` の両方を実装する。
@@ -52,19 +49,17 @@
## 変更を出す前の最低チェック
1. ESLint 対象の変更ファイルへ package root から `eslint --quiet` を最後に 1 回実行し、実装変更には最も近い test を選んで実行する。
package / repo 全体 lint と広域 test は任意
1. `pnpm lint` が通る (typecheck + eslint, 全パッケージ)
2. backend で `meta` / `paramDef` / `res` を変更した → `pnpm build-misskey-js-with-types` を実行し `packages/misskey-js/src/autogen/` の差分も commit に含めた
3. entity / migration を変更した → `pnpm --filter backend check-migrations` が pending DDL 0 件で通る / 新規 migration は `up()` と `down()` 両方実装済
4. `node scripts/check-spdx.mjs` `SPDX: OK` を返し
5. ユーザーが明示しない限り `CHANGELOG.md` を編集しない。
ユーザー影響がある変更では引き継ぎに候補を 1 行だけ示す
6. commit 済み・未commit・untracked の変更集合に `locales/ja-JP.yml` 以外の locale YAML が無いことを確認する
4. 新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.vue` / `.scss` / `.html` ファイルを追加した → SPDX ヘッダーを付け
5. ユーザー影響のある変更 → `CHANGELOG.md` の `## Unreleased` 配下の該当サブセクション (`### General` / `### Client` / `### Server`) に `- <Feat|Enhance|Fix>: <概要>` を 1 行追記
6. `locales/` を編集した場合、`git diff --name-only develop -- 'locales/*.yml' | grep -v '^locales/ja-JP\.yml$'` が空 (ja-JP.yml 以外に差分が無い) ことを確認
## Validation コマンド
- 全体ビルド: `pnpm build`
- 全体 lint / typecheck (任意): `pnpm lint`
- 全体 lint / typecheck: `pnpm lint`
- Backend unit test: `pnpm --filter backend test`
- Backend e2e test: `pnpm --filter backend test:e2e`
- Backend federation test: `pnpm --filter backend test:fed`
+64 -1
View File
@@ -14,4 +14,67 @@ jobs:
- name: Checkout
uses: actions/checkout@v7.0.0
- name: Check
run: node scripts/check-spdx.mjs --ci
run: |
counter=0
search() {
local directory="$1"
find "$directory" -type f \
'(' \
-name "*.cjs" -and -not -name '*.config.cjs' -o \
-name "*.html" -o \
-name "*.js" -and -not -name '*.config.js' -o \
-name "*.mjs" -and -not -name '*.config.mjs' -o \
-name "*.scss" -o \
-name "*.ts" -and -not -name '*.config.ts' -o \
-name "*.vue" \
')' -and \
-not -name '*eslint*'
}
check() {
local file="$1"
if ! (
grep -q "SPDX-FileCopyrightText: syuilo and misskey-project" "$file" ||
grep -q "SPDX-License-Identifier: AGPL-3.0-only" "$file"
); then
echo "Missing: $file"
((counter++))
fi
}
directories=(
"packages/backend/migration"
"packages/backend/src"
"packages/backend/test"
"packages/frontend-shared/@types"
"packages/frontend-shared/js"
"packages/frontend-builder"
"packages/frontend/.storybook"
"packages/frontend/@types"
"packages/frontend/lib"
"packages/frontend/public"
"packages/frontend/src"
"packages/frontend/test"
"packages/frontend-embed/@types"
"packages/frontend-embed/src"
"packages/icons-subsetter/src"
"packages/misskey-bubble-game/src"
"packages/misskey-reversi/src"
"packages/sw/src"
"scripts"
)
for directory in "${directories[@]}"; do
for file in $(search $directory); do
check "$file"
done
done
if [ $counter -gt 0 ]; then
echo "SPDX-License-Identifier is missing in $counter files."
exit 1
else
echo "SPDX-License-Identifier is certainly described in all target files!"
exit 0
fi
-1
View File
@@ -55,7 +55,6 @@ jobs:
# https://github.com/docker/compose/issues/1294#issuecomment-374847206
run: |
cd packages/backend/test-federation
touch ../../../built/._config_.json
docker compose up -d --scale tester=0
- name: Print start_servers error
if: ${{ steps.start_servers.outcome == 'failure' }}
+26 -11
View File
@@ -18,11 +18,28 @@
1. **SPDX ヘッダー欠落のまま AGPL 管轄ディレクトリへ新規ファイルを追加しない**
- 対象: 新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.vue` / `.scss` / `.html` ファイル
- 対象判定は [scripts/check-spdx.mjs](scripts/check-spdx.mjs) が一元管理する
- `node scripts/check-spdx.mjs` を 1 回実行し、欠落は `--fix` で補う。
`SPDX: OK` なら追加の目視確認はしない
- CI の対象判定は [.github/workflows/check-spdx-license-id.yml](.github/workflows/check-spdx-license-id.yml) の `directories` 配列を参照 (`*.config.{ts,js,cjs,mjs}``*eslint*` は除外)
- 欠落すると CI (`spdx` ジョブ) が失敗する
- `packages/misskey-js` は MIT ライセンスのサブパッケージなので、この AGPL ヘッダーを一律に付けない (サブパッケージ固有の `package.json` / `LICENSE` / 既存ファイルのヘッダーに従う)
`.ts` / `.js` / `.cjs` / `.mjs` / `.scss`:
```text
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
```
`.vue` / `.html` (HTML コメント形式):
```text
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
```
2. **`locales/ja-JP.yml` 以外の locale YAML を手動編集しない**
- 他言語ファイル (`en-US.yml` など `ja-JP.yml` 以外すべて) は Crowdin の自動配信先。手動編集すると次の同期で上書き喪失する
- 根拠: [locales/README.md](locales/README.md) と [crowdin.yml](crowdin.yml) (`ja-JP.yml` → `locales/%locale%.yml` の同期設定)
@@ -36,7 +53,7 @@
### Git / リポジトリ操作
4. **`git push --force` / `--force-with-lease` を `main` / `develop` / `master` にしない** (他人の作業を消す可能性)
5. **`git commit --no-verify` で hook をスキップしない**
5. **`git commit --no-verify` で hook をスキップしない** (lint / format / SPDX チェックを潰す)
6. **マージ済 / プッシュ済コミットを `git commit --amend` で書き換えない** (履歴の整合性が壊れる)
7. **他人のブランチを `git reset --hard` / `git branch -D` で破壊しない**
8. **`git config` をユーザーに無断で書き換えない** (特に `user.name` / `user.email` / `commit.gpgsign`)
@@ -63,14 +80,12 @@
各エージェントは [shipping-misskey-change スキル](.claude/skills/shipping-misskey-change/SKILL.md) を参照すること。スキルが利用できない環境でも、以下のチェックは必ず実施すること:
1. **lint / test**: ESLint 対象の変更ファイルへ package root から `eslint --quiet` を最後に 1 回実行し、実装変更には最も近い test を選んで実行する。
package / repo 全体 lint と広域 test は任意
1. **lint**: `pnpm lint` が通る (typecheck + eslint, 全パッケージ)
2. **backend API 変更時**: `pnpm build-misskey-js-with-types` を実行し `packages/misskey-js/src/autogen/` の差分も commit に含めた
3. **entity / migration 変更時**: `pnpm --filter backend check-migrations` が pending DDL 0 件で通る / 新規 migration は `up()` と `down()` 両方実装済
4. **SPDX**: `node scripts/check-spdx.mjs``SPDX: OK` を返すことを確認する
5. **locale safety**: commit 済み・未commit・untracked の変更集合に `locales/ja-JP.yml` 以外の locale YAML が無いことを確認する
6. **[CHANGELOG](.claude/skills/shipping-misskey-change/references/tasks/changelog-update.md)**: ユーザーが明示しない限り編集しない。
ユーザー影響がある変更では引き継ぎに候補を 1 行だけ示す
4. **新規ファイル**: SPDX ヘッダーを付けた (`.vue` / `.html` は HTML コメント形式、それ以外は TS コメント形式)
5. **ユーザー影響のある変更**: `CHANGELOG.md` の `## Unreleased` 配下の該当サブセクション (`### General` / `### Client` / `### Server`) に `- <Feat|Enhance|Fix>: <概要>` を 1 行追記
6. **locale safety**: `locales/` を編集した場合、`git diff --name-only develop -- 'locales/*.yml' | grep -v '^locales/ja-JP\.yml$'` が空 (ja-JP.yml 以外に差分が無い) ことを確認
### Validation commands
@@ -78,7 +93,7 @@
| 用途 | コマンド |
| --- | --- |
| 全体 lint (任意) | `pnpm lint` |
| 全体 lint (typecheck + eslint) | `pnpm lint` |
| Backend unit test | `pnpm --filter backend test` |
| Backend e2e test | `pnpm --filter backend test:e2e` |
| Backend federation test | `pnpm --filter backend test:fed` |
-13
View File
@@ -1,16 +1,3 @@
## 2026.8.0
### General
-
### Client
- Enhance: 画像ビューワーで、ピクセルアートの拡大表示に適したモードを追加(画像ビューワー起動時に画面上の詳細メニューから有効化できます)
- Fix: 画像の表示時にBlurhashが描画されない場合があるのを修正
### Server
- Fix: 既にミュートしているスレッドに対して再度スレッドミュートを作成しようとするとサーバーエラーになる問題を修正
## 2026.7.0
### Note
-293
View File
@@ -1,293 +0,0 @@
---
_lang_: "فارسی"
headlineMisskey: "یک شبکه از طریق یادداشت ها متصل گردید."
introMisskey: "خوش آمدید! Misskey یک سرویس میکروبلاگینگ متن باز و غیرمتمرکز می باشد.\nیک \"یادداشت\" درست کن و در مورد اتفاق هایی که داره می افته و یا به دیگران درمورد خودت بگو📡\nشما می توانید به سرعت با کمک قابلیت \"واکنش ها\" به یادداشت های دیگران واکنش نشون بدید👍\nیک دنیای جدید رو کاوش کنید🚀"
poweredByMisskeyDescription: "{name} یکی از سرور های متن باز پلتفرم <b>Misskey</b> می باشد."
monthAndDay: "{day}/{month}"
search: "جستجو"
reset: "بازنشانی"
notifications: "اعلان ها"
username: "نام کاربری"
password: "گذرواژه"
initialPasswordForSetup: "گذرواژه برای راه‌اندازی تنظیمات اولیه"
initialPasswordIsIncorrect: "گذرواژه برای راه‌اندازی تنظیمات اولیه نادرست می باشد."
initialPasswordForSetupDescription: "اگر خودتان Misskey را نصب کرده اید، لطفا از گذرواژه ای که در فایل تنظیمات وارد کرده اید استفاده کنید.\nاگه از سرویس میزبانی برای Misskey استفاده می کنید، لطفا از گذرواژه ای که توسط آنها ارائه شده استفاده کنید.\nاگر گذرواژه ای تنظیم نکردید، بخش را خالی بگذارید و ادامه دهید."
forgotPassword: "گذرواژه ام را فراموش کردم"
fetchingAsApObject: "در حال دریافت از فدیوِرس"
ok: "باشه"
gotIt: "متوجه شدم"
cancel: "لغو"
noThankYou: "الان نه"
enterUsername: "نام کاربری خود را وارد کنید"
renotedBy: "بازنشر شده توسط {user}"
noNotes: "هیچ یادداشتی موجود نیست"
noNotifications: "اعلانی موجود نیست"
instance: "سرور"
settings: "تنظیمات"
notificationSettings: "تنظیمات اعلان"
basicSettings: "تنظیمات اصلی"
otherSettings: "تنظیمات بیشتر"
openInWindow: "در پنجره باز کن"
profile: "نمایه"
timeline: "نوار زمانی"
noAccountDescription: "این کاربر هنوز درون شرح حال خودش چیزی ننوشته."
login: "ورود"
loggingIn: "وارد شده"
logout: "خروج"
signup: "ثبت‌نام"
uploading: "بارگذاری..."
save: "ذخیره"
users: "کاربران"
addUser: "افزودن کاربر"
favorite: "افزودن به برگزیده ها"
favorites: "برگزیده ها"
unfavorite: "حذف از برگزیده ها"
favorited: "به برگزیده ها اضافه شد."
alreadyFavorited: "قبلا به برگزیده ها اضافه شده."
cantFavorite: "نمی توان به برگزیده ها اضافه کرد."
pin: "سنجاق کردن"
unpin: "حذف سنجاق"
copyContent: "رونوشت از محتوا"
copyLink: "رونوشت از پیوند"
copyRemoteLink: "رونوشت از پیوند خارجی"
copyLinkRenote: "رونوشت از پیوند بازنشر"
delete: "حذف"
deleteAndEdit: "حذف و ویرایش"
deleteAndEditConfirm: "آیا می خواهید که این یادداشت را حذف و دوباره ویرایش کنید؟ در این صورت همچنین تمام واکنش ها، بازنشر ها و پاسخ ها به این یادداشت پاک خواهد شد."
addToList: "افزودن به فهرست"
addToAntenna: "افزودن به آنتن"
sendMessage: "ارسال پیام"
copyRSS: "رونوشت RSS"
copyUsername: "رونوشت نام کاربری"
copyUserId: "رونوشت شناسه کاربری"
copyNoteId: "رونوشت شناسه یادداشت"
copyFileId: "رونوشت شناسه پرونده"
copyFolderId: "رونوشت شناسه پوشه"
copyProfileUrl: "رونوشت ادرس نمایه"
searchUser: "جستجوی کاربران"
searchThisUsersNotes: "جستجو در یادداشت های این کاربر"
reply: "پاسخ"
loadMore: "بارگیری بیشتر"
showMore: "نمایش بیشتر"
showLess: "بستن"
youGotNewFollower: "شما دنبال شده اید"
receiveFollowRequest: "درخواست دنبال کردن دریافت شد"
followRequestAccepted: "درخواست دنبال کردن قبول شد"
mention: "اشاره"
mentions: "اشاره کردن"
directNotes: "یادداشت مستقیم"
importAndExport: "واردسازی و خارج‌سازی"
import: "واردسازی"
export: "خارج‌سازی"
files: "پرونده ها"
download: "بارگیری"
driveFileDeleteConfirm: "آیا مطمئن هستید که می خواهید \"{name}\" را حذف کنید؟ تمام یادداشت هایی که این فایل به آنها پیوست خورده اند نیز پاک خواهد شد."
unfollowConfirm: "آیا می‌خواهید دیگر {name} را دنبال نکنید؟"
cancelFollowRequestConfirm: "آیا می خواهید درخواست دنبال کردن از {name} را لغو کنید؟"
rejectFollowRequestConfirm: "آیا می خواهید درخواست دنبال کردن از طرف {name} را رد کنید؟"
exportRequested: "شما یک درخواست خارج‌سازی کرده اید. این ممکن است کمی طول بکشد. وقتی که به اتمام رسید در حافظه شما ذخیره خواهد شد."
importRequested: "شما یک درخواست واردسازی داشتید. ممکن است زمان ببرد."
lists: "فهرست ها"
noLists: "فهرستی موجود نیست"
note: "یادداشت"
notes: "یادداشت ها"
following: "دنبال می‌کنید"
followers: "دنبال کنندگان"
followsYou: "شما را دنبال می کند"
createList: "ایجاد فهرست"
manageLists: "مدیریت فهرست ها"
error: "خطا"
somethingHappened: "یک مشکلی پیش آمده"
retry: "تلاش مجدد"
pageLoadError: "در هنگام بارگیری صفحه یک مشکلی به وجود آمده"
pageLoadErrorDescription: "این معمولا به خاطر مشکل در شبکه و یا حافظه پنهان اتفاق می افتد. سعی کنید حافظه کش(پنهان) را خالی کنید و یا مدتی صبر کنید و دوباره امتحان کنید."
serverIsDead: "سرور پاسخگو نیست. لطفا مدتی صبر کنید و دوباره تلاش کنید."
youShouldUpgradeClient: "برای دیدن این صفحه، لطفا صفحه را تازه‌کنید و از نسخه جدیدتر نرم‌افزار استفاده کنید."
enterListName: "نامی برای فهرست وارد کنید"
privacy: "حریم خصوصی"
makeFollowManuallyApprove: "درخواست دنبال کردن فقط با تایید کاربر مجاز است"
defaultNoteVisibility: "پیشفرض نمایش یادداشت"
follow: "دنبال می‌کنید"
followRequest: "درخواست های دنبال‌کردن"
followRequests: "درخواست‌های دنبال‌کردن"
unfollow: "لغو دنبال‌کردن"
followRequestPending: "منتظر برای تایید درخواست دنبال‌کردن"
enterEmoji: "یک شکلک وارد کنید"
renote: "بازنشر"
unrenote: "حذف بازنشر"
renoted: "بازنشر شده."
renotedToX: "به {name} بازنشر شده."
cantRenote: "این پیام را نمی توان بازنشر کرد."
cantReRenote: "نمی شود یک بازنشر شده را دوباره بازنشر کرد."
quote: "نقل‌قول"
inChannelRenote: "بازنشر فقط در کانال"
inChannelQuote: "نقل‌قول فقط در کانال"
renoteToChannel: "بازنشر در کانال"
renoteToOtherChannel: "بازنشر در کانال‌های دیگر"
pinnedNote: "یادداشت سنجاق شده"
pinned: "سنجاق کردن"
you: "شما"
clickToShow: "کلیک به جهت نمایش"
sensitive: "محتوای حساس"
add: "اضافه"
reaction: "واکنش‌ها"
reactions: "واکنش‌ها"
emojiPicker: "انتخابگر شکلک"
pinnedEmojisForReactionSettingDescription: "شما می توانید شکلک ها را به گونه ای تنظیم کنید که به صورت سنجاق شده هنگام واکنش دادن به نمایش در بیایند."
pinnedEmojisSettingDescription: "شما می توانید شکلک ها را به گونه ای تنظیم کنید که به صورت سنجاق شده هنگام واردکردن شکلک ها به نمایش در بیایند."
emojiPickerDisplay: "نمایشگر انتخابگر شکلک ها"
overwriteFromPinnedEmojisForReaction: "باطل کردن تنظیمات واکنش ها"
overwriteFromPinnedEmojis: "باطل کردن تنظیمات عمومی (شکلک ها)"
reactionSettingDescription2: "برای مرتب کردن بکشید، برای حذف کلیک کنید، برای اضافه کردن بروی \"+\" کلیک کنید."
rememberNoteVisibility: "تنظیمات نمایش یادداشت‌ها را به خاطر بسپر"
attachCancel: "حذف ضمیمه"
deleteFile: "حذف پرونده‌"
markAsSensitive: "علامت به عنوان حساس"
unmarkAsSensitive: "برداشتن علامت حساس"
enterFileName: "نام پرونده را وارد کن"
mute: "سکوت"
unmute: "لغو سکوت"
renoteMute: "سکوت بازنشر"
renoteUnmute: "لغو سکوت بازنشر"
block: "مسدود کردن"
unblock: "رفع مسدودیت"
suspend: "انجماد"
unsuspend: "ذوب شدن"
blockConfirm: "آیا می خواهید این حساب مسدود شود؟"
unblockConfirm: "آیا می خواهید مسدودیت این حساب رفع شود؟"
suspendConfirm: "آیا می خواهید منجمد شود؟"
unsuspendConfirm: "آیا می خواهید یخ زدایی شود؟"
selectList: "انتخاب یک فهرست"
editList: "ویرایش فهرست"
selectChannel: "انتخاب یک کانال"
selectAntenna: "انتخاب یک آنتن"
editAntenna: "ویرایش آنتن"
createAntenna: "ایجاد یک آنتن"
selectWidget: "انتخاب یک ابزارک"
editWidgets: "ویرایش یک ابزارک"
editWidgetsExit: "پایان ویرایش"
customEmojis: "شکلک سفارشی"
emoji: "شکلک"
emojis: "شکلک"
emojiName: "اسم شکلک"
emojiUrl: "پیوند شکلک"
addEmoji: "افزودن شکلک"
settingGuide: "تنظیمات پیشنهادی"
cacheRemoteFiles: "کش کردن پرونده های خارجی"
cacheRemoteFilesDescription: "در صورت فعال کردن این قابلیت پرونده های خارجی (در سرور های دیگر) در فضای ذخیره این سرور بارگیری می شود. این کار سرعت نمایش تصاویر را افزایش می دهد، اما با این کار فضای داخلی سرور بیشتر اشغال می شود. مدت زمانی که هر کاربر می تواند از سرور خارجی کش کند، براساس محدودیت ظرفیت حافظه اعمال شده بر نقش اوست. اگر از این محدودیت فراتر رود پرونده های قدیمی حافظه پنهان (کش) حذف و تبدیل به لینک پیوند می شود. اگر این تنظیم غیرفعال باشد پرونده های خارجی از همان ابتدا به صورت لینک پیوند ذخیره می شود.\n"
youCanCleanRemoteFilesCache: "شما می توانید حافظه پنهان (کش) را با کلیک بر دکمه 🗑️ در پایین صفحه مدیریت پرونده ها حذف کنید."
cacheRemoteSensitiveFiles: "کش کردن پرونده های حساس سرور های خارجی"
cacheRemoteSensitiveFilesDescription: "با غیر فعال کردن این قابلیت پرونده هایی که علامت حساس خورده اند در سرور های خارجی به جای اینکه در حافظه پنهان ذخیره شوند، مستقیما به ان پیوند داده می شوند."
flagAsBot: "این اکانت را به عنوان ربات علامت گذاری کن"
flagAsBotDescription: "این قابلیت را اگه این اکانت توسط یک ربات کنترل می شود فعال کنید. در صورت فعال شدن، این به عنوان یک پرچم برای برنامه نویس های دیگر عمل می کند تا جلوی تعامل های بی پایان با ربات های دیگر را بگیرد و همچنین سیستم داخلی Misskey با این اکانت به صورت ربات رفتار می‌کند."
flagAsCat: "میووووووووووووووووو!!!!!!!!!"
flagAsCatDescription: "میو میو میو؟؟"
flagShowTimelineReplies: "نمایش پاسخ های یادداشت به صورت خط زمانی"
flagShowTimelineRepliesDescription: "وقتی این قابلیت فعال شود، نه تنها یادداشت های کاربران بلکه پاسخ های کاربران به یادداشت ها را در خط زمانی به نمایش می گذارد."
autoAcceptFollowed: "به صورت خودکار درخواست های دنبال کردن را از کاربرانی که دنبال می کنید را قبول کن"
addAccount: "افزودن حساب کاربری"
reloadAccountsList: "بارگذاری مجدد فهرست حساب ها"
loginFailed: "مشکل در وارد شدن"
showOnRemote: "دیدن از سرور‌خارجی"
continueOnRemote: "ادامه در سرورخارجی"
chooseServerOnMisskeyHub: "یک سرور را از Misskey Hub انتخاب کنید"
specifyServerHost: "یک سرور را به طور مستقیم تعیین کنید"
inputHostName: "دامنه را وارد کنید"
general: "عمومی"
wallpaper: "تصویر پس‌زمینه"
setWallpaper: "تنظیم تصویر پس‌زمینه"
removeWallpaper: "حذف تصویر پس‌زمینه"
searchWith: "جستجو: {q}"
youHaveNoLists: "شما فهرستی ندارید"
followConfirm: "آیا می خواهید {name} را دنبال کنید؟"
proxyAccount: "حساب وکیل"
host: "میزبان"
selectSelf: "انتخاب خودت"
selectUser: "انتخاب کاربر"
recipient: "گیرنده"
annotation: "حاشیه نویسی"
instances: "سرور"
registeredAt: "ثبت شده در"
latestRequestReceivedAt: "آخرین درخواست دریافت شده"
latestStatus: "جدیدترین وضعیت"
storageUsage: "حافظه مصرفی"
charts: "نمودارها"
perHour: "در ساعت"
cpuAndMemory: "پردازنده و حافظه رم"
network: "شبکه"
disk: "حافظه ذخیره"
instanceInfo: "اطلاعات سرور"
noCustomEmojis: "هیچ شکلکی نیست"
remove: "حذف"
pinnedNotes: "یادداشت سنجاق شده"
userList: "فهرست ها"
emojiStyle: "سبک شکلک"
chooseEmoji: "انتخاب یک شکلک"
smtpHost: "میزبان"
smtpUser: "نام کاربری"
smtpPass: "گذرواژه"
theKeywordWhenSearchingForCustomEmoji: "این کلمه کلیدی است که هنگام جستجو در میان شکلک های سفارشی به کار گرفته می شود."
user: "کاربران"
searchByGoogle: "جستجو"
file: "پرونده ها"
manageCustomEmojis: "مدیریت شکلک های سفارشی"
rolesThatCanBeUsedThisEmojiAsReaction: "نقش هایی که اجازه دارند تا از این شکلک برای واکنش استفاده بکنند"
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "اگر نقشی مشخص نشود، هر کسی اجازه استفاده از این شکلک به عنوان واکنش را دارا می باشند."
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "این نقش ها باید عمومی باشند."
replies: "پاسخ"
renotes: "بازنشر"
_role:
_options:
canManageCustomEmojis: "مدیریت شکلک های سفارشی"
_email:
_follow:
title: "شما دنبال شده اید"
_theme:
keys:
mention: "اشاره کردن"
renote: "بازنشر"
_sfx:
note: "یادداشت ها"
notification: "اعلان ها"
_2fa:
renewTOTPCancel: "الان نه"
_widgets:
profile: "نمایه"
instanceInfo: "اطلاعات سرور"
notifications: "اعلان ها"
timeline: "نوار زمانی"
_userList:
chooseList: "انتخاب یک فهرست"
_cw:
show: "بارگیری بیشتر"
_visibility:
followers: "دنبال کنندگان"
specified: "یادداشت مستقیم"
_profile:
username: "نام کاربری"
_exportOrImport:
followingList: "دنبال می‌کنید"
muteList: "سکوت"
blockingList: "مسدود کردن"
userLists: "فهرست ها"
_notification:
youWereFollowed: "شما دنبال شده اید"
_types:
follow: "دنبال می‌کنید"
mention: "اشاره کردن"
renote: "بازنشر"
quote: "نقل‌قول"
reaction: "واکنش‌ها"
login: "ورود"
_actions:
reply: "پاسخ"
renote: "بازنشر"
_deck:
_columns:
notifications: "اعلان ها"
tl: "نوار زمانی"
list: "فهرست ها"
mentions: "اشاره کردن"
direct: "یادداشت مستقیم"
_moderationLogTypes:
suspend: "انجماد"
-1
View File
@@ -1421,7 +1421,6 @@ append: "末尾に追加"
prepend: "先頭に追加"
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
pixelatedZoom: "ピクセルアート拡大モード"
_imageEditing:
_vars:
-15
View File
@@ -619,8 +619,6 @@ output: "輸出"
script: "腳本"
disablePagesScript: "停用頁面的 AiScript 腳本"
updateRemoteUser: "更新遠端使用者資訊"
unsetMfa: "解除雙重驗證"
unsetMfaConfirm: "要解除雙重驗證嗎?"
unsetUserAvatar: "移除使用者的大頭貼"
unsetUserAvatarConfirm: "確定要移除使用者的大頭貼嗎?"
unsetUserBanner: "移除使用者的橫幅圖像"
@@ -1419,8 +1417,6 @@ addToEmojiPalette: "增加表情符號調色盤"
emojiPaletteAlreadyAddedConfirm: "此表情符號在這個表情符號調色盤裡已經有了。確定要增加嗎?"
append: "加在最後"
prepend: "加在前面"
urlPreviewSensitiveList: "限制縮圖顯示的 URL"
urlPreviewSensitiveListDescription: "以空格指定為 AND,以換行指定為 OR。若以斜線(/)包圍則視為正規表達式。符合條件時,將不再顯示縮圖。"
_imageEditing:
_vars:
caption: "檔案標題"
@@ -2153,15 +2149,6 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "即使將此設定關閉,判定結果也會保留在內部。"
analyzeVideos: "啟用影片分析"
analyzeVideosDescription: "除了靜止影像以外,也分析影片。伺服器的負荷會稍微增加。"
externalServiceInfo: "敏感媒體的判定已分離至外部服務(sensitive-detector)。若要使用此功能,必須另外設定 Sidecar 服務,並設定下方的連接資訊。若未設定連接資訊,則不會執行判定(視為非敏感內容)。"
apiUrl: "判定服務的連接資訊 URL"
apiUrlDescription: "sensitive-detector 服務的基礎網址(例如:http://localhost:3009)。若要連接位於私有網路上的服務,請在設定檔的 allowedPrivateNetworks 中允許對應的連接網路。若使用代理伺服器(Proxy),也請一併設定 proxyBypassHosts。若留白,則不會執行敏感內容判定。"
apiKey: "API 金鑰"
apiKeyDescription: "若判定服務端已設定認證(Bearer Token),請輸入此資訊。若未設定,請保持空白。"
timeout: "連線逾時(微秒)"
timeoutDescription: "單次判定請求的逾時時間。"
maxImagesPerRequest: "每筆請求的最大圖片數量"
maxImagesPerRequestDescription: "當判定包含多個影格的內容(如影片等)時,為每筆請求所允許的單次最大圖片數量。超過此數量的部分將會分割並依序發送。請確保此數值不超過 sensitive-detector 端的 maxParts 設定(預設值:10)。若超過該數值,該區塊(Chunk)的所有項目將被視為非敏感內容。"
_emailUnavailable:
used: "已被使用"
format: "格式無效"
@@ -2474,7 +2461,6 @@ _permissions:
"read:admin:show-moderation-log": "查看審查紀錄"
"read:admin:show-user": "查看使用者的私密資訊"
"write:admin:suspend-user": "凍結使用者"
"write:admin:unset-mfa": "解除使用者的雙重驗證"
"write:admin:unset-user-avatar": "刪除使用者的頭像"
"write:admin:unset-user-banner": "刪除使用者的橫幅"
"write:admin:unsuspend-user": "解除凍結使用者"
@@ -2990,7 +2976,6 @@ _moderationLogTypes:
createAvatarDecoration: "建立頭像裝飾"
updateAvatarDecoration: "更新頭像裝飾"
deleteAvatarDecoration: "刪除頭像裝飾"
unsetMfa: "解除使用者的雙重驗證"
unsetUserAvatar: "移除使用者的大頭貼"
unsetUserBanner: "移除使用者的橫幅圖像"
createSystemWebhook: "建立 SystemWebhook"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.8.0-alpha.0",
"version": "2026.7.0-rc.0",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -131,7 +131,7 @@ export function chunkMarkdownTable(
if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_';
const lines = [
'| Chunk | Base | Head | Δ | Δ\u00A0(%) |', // nbspにすること
'| Chunk | Base | Head | Δ | Δ (%) |', // nbspにすること
'| --- | ---: | ---: | ---: | ---: |',
];
if (total != null) {
@@ -151,7 +151,7 @@ export function chunkMarkdownTable(
}
if (hasGenerated) {
// eslint-disable-next-line no-irregular-whitespace
lines.push(`| (other\u00A0generated\u00A0chunks) | ${formatBytes(generated.baseSize)} | ${formatBytes(generated.headSize)} | ${calcAndFormatDeltaBytes(generated.baseSize, generated.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.baseSize, generated.headSize, 0.1)} |`); // nbspにすること
lines.push(`| (other generated chunks) | ${formatBytes(generated.baseSize)} | ${formatBytes(generated.headSize)} | ${calcAndFormatDeltaBytes(generated.baseSize, generated.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.baseSize, generated.headSize, 0.1)} |`); // nbspにすること
}
if (hasOther) {
lines.push(`| (other) | ${formatBytes(other.baseSize)} | ${formatBytes(other.headSize)} | ${calcAndFormatDeltaBytes(other.baseSize, other.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.baseSize, other.headSize, 0.1)} |`);
@@ -177,8 +177,8 @@ test('renders the difference of independent medians instead of the paired median
const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests');
expect(row).toContain('100 <br> ±\u00A00');
expect(row).toContain('200 <br> ±\u00A00');
expect(row).toContain('100 <br> ± 0');
expect(row).toContain('200 <br> ± 0');
expect(row).toContain('$\\color{orange}{\\text{+100}}$<br>$\\color{orange}{\\text{+100\\\\%}}$');
expect(row).toContain('| 0 |');
expect(row.split('|')).toHaveLength(7);
@@ -251,7 +251,7 @@ test('renders a directional encoded byte delta at the absolute threshold', async
const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network');
expect(row).toContain('$\\color{orange}{\\text{+10\u00A0KB}}$');
expect(row).toContain('$\\color{orange}{\\text{+10 KB}}$');
});
test('colours absolute and relative deltas together when the row is significant', async () => {
@@ -268,9 +268,9 @@ test('colours absolute and relative deltas together when the row is significant'
const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network');
expect(row).toContain('100\u00A0MB <br> ±\u00A00\u00A0B');
expect(row).toContain('$\\color{orange}{\\text{+20\u00A0KB}}$<br>$\\color{orange}{\\text{+0\\\\%}}$');
expect(row).toContain('| 0\u00A0B |');
expect(row).toContain('100 MB <br> ± 0 B');
expect(row).toContain('$\\color{orange}{\\text{+20 KB}}$<br>$\\color{orange}{\\text{+0\\\\%}}$');
expect(row).toContain('| 0 B |');
expect(row.split('|')).toHaveLength(7);
});
@@ -33,7 +33,7 @@ export function formatNumber(value: number) {
}
export function formatBytes(value: number) {
if (value === 0) return '0\u00A0B'; // nbspにすること
if (value === 0) return '0 B'; // nbspにすること
const units = ['B', 'KB', 'MB', 'GB'];
let unitIndex = 0;
let size = value;
@@ -44,7 +44,7 @@ export function formatBytes(value: number) {
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
// eslint-disable-next-line no-irregular-whitespace
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))}\u00A0${units[unitIndex]}`; // nbspにすること
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`; // nbspにすること
}
/**
@@ -21,7 +21,7 @@ function categoryValue(report: HeapSnapshotReport, category: HeapSnapshotCategor
function swatch(category: HeapSnapshotCategory) {
// eslint-disable-next-line no-irregular-whitespace
return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$\u00A0**${heapSnapshotCategory[category].label.replaceAll(' ', '\u00A0')}**`; // nbspにすること
return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label.replaceAll(' ', ' ')}**`; // nbspにすること
}
/**
@@ -36,7 +36,7 @@ function formatMedian<T>(
const formatted = row.formatValue(value);
if (row.showMedianMad === false) return formatted;
// eslint-disable-next-line no-irregular-whitespace
return `${formatted} <br> ±\u00A0${row.formatValue(spread)}`; // nbspにすること
return `${formatted} <br> ± ${row.formatValue(spread)}`; // nbspにすること
}
function formatDelta<T>(
@@ -20,15 +20,15 @@ import {
describe('formatBytes', () => {
// 1024ではなく1000区切り。単位が2桁以上なら小数を落とす
test.each([
[0, '0\u00A0B'],
[999, '999\u00A0B'],
[1_000, '1\u00A0KB'],
[1_500, '1.5\u00A0KB'],
[15_000, '15\u00A0KB'],
[1_234_567, '1.2\u00A0MB'],
[12_345_678, '12\u00A0MB'],
[1_500_000_000, '1.5\u00A0GB'],
[1_500_000_000_000, '1,500\u00A0GB'],
[0, '0 B'],
[999, '999 B'],
[1_000, '1 KB'],
[1_500, '1.5 KB'],
[15_000, '15 KB'],
[1_234_567, '1.2 MB'],
[12_345_678, '12 MB'],
[1_500_000_000, '1.5 GB'],
[1_500_000_000_000, '1,500 GB'],
])('formats %i as %s', (input, expected) => {
expect(formatBytes(input)).toBe(expected);
});
@@ -45,7 +45,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_100_000, 1_200_000, 1_300_000]),
));
expect(row).toContain('$\\text{+100\u00A0KB}$');
expect(row).toContain('$\\text{+100 KB}$');
expect(row).not.toContain('within noise');
expect(row).not.toContain('\\color{orange}');
});
@@ -56,7 +56,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_200_000, 1_200_000, 1_200_000]),
));
expect(row).toContain('$\\color{orange}{\\text{+200\u00A0KB}}$');
expect(row).toContain('$\\color{orange}{\\text{+200 KB}}$');
expect(row).toContain('$\\color{orange}{\\text{+20\\\\%}}$');
expect(row).not.toContain('increase');
});
@@ -67,7 +67,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_050_000, 1_050_000, 1_050_000]),
));
expect(row).toContain('$\\text{+50\u00A0KB}$<br>$\\text{+5\\\\%}$');
expect(row).toContain('$\\text{+50 KB}$<br>$\\text{+5\\\\%}$');
expect(row.split('|')[4]).not.toContain('\\color{');
});
@@ -33,7 +33,7 @@ describe('renderMetricComparisonTable', () => {
expect(table.split('\n')).toStrictEqual([
'| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
'| <strong>Metric</strong> | 100 units <br> ±\u00A00 units | 120 units <br> ±\u00A00 units | $\\color{orange}{\\text{+20~units}}$<br>$\\color{orange}{\\text{+20\\\\%}}$ | 0 units |',
'| <strong>Metric</strong> | 100 units <br> ± 0 units | 120 units <br> ± 0 units | $\\color{orange}{\\text{+20~units}}$<br>$\\color{orange}{\\text{+20\\\\%}}$ | 0 units |',
]);
});
@@ -4,6 +4,7 @@
*/
import { Injectable, Inject } from '@nestjs/common';
import fetch from 'node-fetch';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@@ -69,7 +70,7 @@ function isDetectImagesResponse(v: unknown): v is DetectImagesResponse {
const DETECT_IMAGES_PATH = 'v1/detect-images';
@Injectable()
export class SensitiveMediaDetectionService {
export class AiService {
private logger: Logger;
constructor(
@@ -79,7 +80,7 @@ export class SensitiveMediaDetectionService {
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('sensitive-media-detection');
this.logger = this.loggerService.getLogger('ai');
}
/**
@@ -130,6 +131,9 @@ export class SensitiveMediaDetectionService {
@bindThis
private async detectChunk(url: string, apiKey: string | null, timeout: number, chunk: Buffer[]): Promise<(Prediction[] | null)[]> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const form = new FormData();
for (let i = 0; i < chunk.length; i++) {
@@ -144,13 +148,14 @@ export class SensitiveMediaDetectionService {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const res = await this.httpRequestService.send(url, {
const res = await fetch(url, {
method: 'POST',
headers,
body: form,
timeout,
}, {
throwErrorWhenResponseNotOk: false,
// 外部サービスとして通常の proxy / private address 制限を適用する。
// サイドカーへの private network 接続は allowedPrivateNetworks 等で明示的に許可する。
agent: (u) => this.httpRequestService.getAgentByUrl(u),
signal: controller.signal,
});
if (!res.ok) {
@@ -176,6 +181,8 @@ export class SensitiveMediaDetectionService {
} catch (err) {
this.logger.warn(`sensitive detection error: ${err instanceof Error ? err.message : String(err)}`);
return chunk.map(() => null);
} finally {
clearTimeout(timer);
}
}
}
+6 -6
View File
@@ -18,7 +18,7 @@ import { FlashService } from '@/core/FlashService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { SensitiveMediaDetectionService } from './SensitiveMediaDetectionService.js';
import { AiService } from './AiService.js';
import { AnnouncementService } from './AnnouncementService.js';
import { AntennaService } from './AntennaService.js';
import { AchievementService } from './AchievementService.js';
@@ -164,7 +164,7 @@ const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisti
const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService };
const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService };
const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService };
const $SensitiveMediaDetectionService: Provider = { provide: 'SensitiveMediaDetectionService', useExisting: SensitiveMediaDetectionService };
const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService };
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService };
@@ -317,7 +317,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AbuseReportNotificationService,
AccountMoveService,
AccountUpdateService,
SensitiveMediaDetectionService,
AiService,
AnnouncementService,
AntennaService,
AchievementService,
@@ -468,7 +468,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AbuseReportNotificationService,
$AccountMoveService,
$AccountUpdateService,
$SensitiveMediaDetectionService,
$AiService,
$AnnouncementService,
$AntennaService,
$AchievementService,
@@ -619,7 +619,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AbuseReportNotificationService,
AccountMoveService,
AccountUpdateService,
SensitiveMediaDetectionService,
AiService,
AnnouncementService,
AntennaService,
AchievementService,
@@ -769,7 +769,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AbuseReportNotificationService,
$AccountMoveService,
$AccountUpdateService,
$SensitiveMediaDetectionService,
$AiService,
$AnnouncementService,
$AntennaService,
$AchievementService,
+5 -5
View File
@@ -16,12 +16,12 @@ import probeImageSize from 'probe-image-size';
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
import * as blurhash from 'blurhash';
import { createTempDir } from '@/misc/create-temp.js';
import { SensitiveMediaDetectionService } from '@/core/SensitiveMediaDetectionService.js';
import { AiService } from '@/core/AiService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js';
import type { Prediction } from '@/core/SensitiveMediaDetectionService.js';
import type { Prediction } from '@/core/AiService.js';
export type FileInfo = {
size: number;
@@ -54,7 +54,7 @@ export class FileInfoService {
private logger: Logger;
constructor(
private sensitiveMediaDetectionService: SensitiveMediaDetectionService,
private aiService: AiService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('file-info');
@@ -266,7 +266,7 @@ export class FileInfoService {
fs.promises.unlink(path);
}
}
const predictions = await this.sensitiveMediaDetectionService.detectSensitiveMany(frameBuffers);
const predictions = await this.aiService.detectSensitiveMany(frameBuffers);
const results = predictions.filter((x): x is Prediction[] => x != null).map(x => judgePrediction(x));
// 判定に成功したフレームが 0 件のとき(接続先未設定・通信失敗等)は、
// Math.ceil(0) との比較が 0 >= 0 で真になり全動画がセンシティブ扱いになってしまうため、
@@ -291,7 +291,7 @@ export class FileInfoService {
.flatten({ background: { r: 119, g: 119, b: 119 } }) // 透過部分を18%グレーで塗りつぶす
.png()
.toBuffer();
const result = await this.sensitiveMediaDetectionService.detectSensitive(png);
const result = await this.aiService.detectSensitive(png);
if (result) {
[sensitive, porn] = judgePrediction(result);
}
@@ -19,7 +19,7 @@ import { bindThis } from '@/decorators.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
import type { IObject } from '@/core/activitypub/type.js';
import type { BodyInit, Response } from 'node-fetch';
import type { Response } from 'node-fetch';
import type { URL } from 'node:url';
export type HttpRequestSendOptions = {
@@ -311,7 +311,7 @@ export class HttpRequestService {
url: string,
args: {
method?: string,
body?: BodyInit,
body?: string,
headers?: Record<string, string>,
timeout?: number,
size?: number,
+2
View File
@@ -141,6 +141,8 @@ export class PageService {
eyeCatchingImageId: body.eyeCatchingImage === undefined ? undefined : (body.eyeCatchingImage?.id ?? null),
});
console.log('page.content', page.content);
if (body.content != null) {
const beforeReferencedNotes = this.collectReferencedNotes(page.content);
const afterReferencedNotes = this.collectReferencedNotes(body.content);
+17 -22
View File
@@ -132,29 +132,24 @@ function normalizeString(value: string, maxBytes: number): string {
return value.slice(0, end) + suffix;
}
function charUtf8Len(codePoint: number): number {
return codePoint < 0x80 ? 1
: codePoint < 0x800 ? 2
: codePoint < 0x10000 ? 3
: 4;
}
function charUtf16Len(codePoint: number): number {
return codePoint < 0x10000 ? 1 : 2;
}
function findMaxPrefixLength(value: string, suffix: string, maxBytes: number) {
let usedBytes = byteLength(suffix);
let prefixLength = 0;
while (prefixLength < value.length) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- similar to for-of, but for-of requires additional allocations
const cp = value.codePointAt(prefixLength)!;
const charBytes = charUtf8Len(cp);
if (usedBytes + charBytes > maxBytes) break;
usedBytes += charBytes;
prefixLength += charUtf16Len(cp);
/** 指定した後置文字列を含めて上限に収まる接頭辞の長さを二分探索します。 */
function findMaxPrefixLength(value: string, suffix: string, maxBytes: number): number {
let lower = 0;
let upper = value.length;
while (lower < upper) {
const middle = Math.ceil((lower + upper) / 2);
if (byteLength(value.slice(0, middle) + suffix) <= maxBytes) {
lower = middle;
} else {
upper = middle - 1;
}
}
return prefixLength;
// UTF-16のサロゲート対を途中で切らないよう、必要なら1文字戻します。
if (lower > 0 && lower < value.length) {
const code = value.charCodeAt(lower - 1);
if (code >= 0xd800 && code <= 0xdbff) lower--;
}
return lower;
}
/** 特殊な値に対しても、エラー判定で例外を発生させないようにします。 */
@@ -73,7 +73,7 @@ export class PrettyConsoleBackend implements LogBackend {
presentationLevel === 'debug' ? chalk.gray(record.message) :
presentationLevel === 'info' ? record.message :
null;
// 主プロセスは「*」で示し、子プロセスはワーカー番号で識別します。
// 主プロセスは従来どおり「*」、子プロセスはワーカー番号で識別します。
const worker = record.isPrimary ? '*' : record.workerId;
let log = `${label} ${worker}\t[${contexts.join(' ')}]\t${message}`;
@@ -12,7 +12,7 @@ export const meta = {
requireCredential: true,
requireModerator: true,
kind: 'read:admin:queue',
kind: 'read:admin:emoji',
res: {
type: 'object',
@@ -30,12 +30,6 @@ export const meta = {
code: 'NO_SUCH_NOTE',
id: '5ff67ada-ed3b-2e71-8e87-a1a421e177d2',
},
alreadyMuting: {
message: 'You are already muting that thread.',
code: 'ALREADY_MUTING',
id: 'c146e22d-1141-4b31-b28d-176371014d18',
},
},
} as const;
@@ -65,18 +59,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw err;
});
// Check if already muting
const exist = await this.noteThreadMutingsRepository.exists({
where: {
const _mutedNotes = await this.notesRepository.find({
where: [{
id: note.threadId ?? note.id,
}, {
threadId: note.threadId ?? note.id,
userId: me.id,
},
}],
});
if (exist) {
throw new ApiError(meta.errors.alreadyMuting);
}
await this.noteThreadMutingsRepository.insert({
id: this.idService.gen(),
threadId: note.threadId ?? note.id,
@@ -4,4 +4,3 @@ volumes
docker.env
*.test.conf
*.test.default.yml
*.test.config.json
+1 -1
View File
@@ -28,7 +28,7 @@ function generate {
-days 500
if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi
if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi
if [ ! -f .config/$1.config.json ]; then sed "s/\${HOST}/$1/g" .config/example.config.json > .config/$1.config.json; fi
if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.config.json > .config/$1.config.json; fi
}
generate a.test
@@ -4,12 +4,19 @@
*/
import { describe, test, expect, vi, beforeEach } from 'vitest';
import type { AiService as AiServiceType, Prediction } from '@/core/AiService.js';
import type { MiMeta } from '@/models/_.js';
import type { HttpRequestService } from '@/core/HttpRequestService.js';
import type { LoggerService } from '@/core/LoggerService.js';
import { SensitiveMediaDetectionService, type Prediction } from '@/core/SensitiveMediaDetectionService.js';
const sendMock = vi.fn();
// AiService が直接 import している node-fetch をモックして、外部サービスへの送信内容と
// レスポンス解釈を検証する。
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
vi.mock('node-fetch', () => ({ default: fetchMock }));
const { AiService } = await import('@/core/AiService.js');
let getAgentByUrlMock: ReturnType<typeof vi.fn>;
const DEFAULT_META = {
sensitiveMediaDetectionApiUrl: 'http://localhost:3009' as string | null,
@@ -18,13 +25,14 @@ const DEFAULT_META = {
sensitiveMediaDetectionMaxImagesPerRequest: 4,
};
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): SensitiveMediaDetectionService {
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): AiServiceType {
const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta;
const httpRequestService = { send: sendMock } as unknown as HttpRequestService;
getAgentByUrlMock = vi.fn(() => undefined);
const httpRequestService = { getAgentByUrl: getAgentByUrlMock } as unknown as HttpRequestService;
const loggerService = {
getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }),
} as unknown as LoggerService;
return new SensitiveMediaDetectionService(meta, httpRequestService, loggerService);
return new AiService(meta, httpRequestService, loggerService);
}
function neutral(): Prediction[] {
@@ -42,13 +50,13 @@ function okResponse(results: unknown[]) {
const buf = (s: string) => Buffer.from(s);
describe('SensitiveMediaDetectionService', () => {
describe('AiService', () => {
beforeEach(() => {
sendMock.mockReset();
fetchMock.mockReset();
});
test('正常: 送信順を保った予測値配列を返す', async () => {
sendMock.mockResolvedValue(okResponse([
fetchMock.mockResolvedValue(okResponse([
{ success: true, predictions: neutral() },
{ success: true, predictions: [{ className: 'Porn', probability: 0.8 }] },
]));
@@ -58,35 +66,30 @@ describe('SensitiveMediaDetectionService', () => {
[{ className: 'Neutral', probability: 0.99 }],
[{ className: 'Porn', probability: 0.8 }],
]);
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images');
});
test('外部サービス: HttpRequestService を使用する', async () => {
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
test('外部サービス: 通常の outbound agent を使用する', async () => {
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const svc = makeService({ sensitiveMediaDetectionApiUrl: 'https://detector.example.com' });
await svc.detectSensitiveMany([buf('a')]);
expect(sendMock).toHaveBeenCalledWith('https://detector.example.com/v1/detect-images', {
method: 'POST',
headers: {},
body: expect.any(FormData),
timeout: 5000,
}, {
throwErrorWhenResponseNotOk: false,
});
const requestOptions = fetchMock.mock.calls[0][1] as { agent: (url: URL) => unknown };
requestOptions.agent(new URL('https://detector.example.com/v1/detect-images'));
expect(getAgentByUrlMock).toHaveBeenCalledWith(expect.any(URL));
});
test('detectSensitive: 単一画像はバッチの先頭を返す', async () => {
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const svc = makeService();
const res = await svc.detectSensitive(buf('a'));
expect(res).toEqual(neutral());
});
test('部分失敗: 失敗パーツのみ null になる', async () => {
sendMock.mockResolvedValue(okResponse([
fetchMock.mockResolvedValue(okResponse([
{ success: true, predictions: neutral() },
{ success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'x' } },
]));
@@ -97,14 +100,14 @@ describe('SensitiveMediaDetectionService', () => {
});
test('非200: チャンク全件 null(例外を投げない)', async () => {
sendMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) });
fetchMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) });
const svc = makeService();
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
expect(res).toEqual([null, null]);
});
test('通信エラー: チャンク全件 null(例外を投げない)', async () => {
sendMock.mockRejectedValue(new Error('network down'));
fetchMock.mockRejectedValue(new Error('network down'));
const svc = makeService();
const res = await svc.detectSensitiveMany([buf('a')]);
expect(res).toEqual([null]);
@@ -114,11 +117,11 @@ describe('SensitiveMediaDetectionService', () => {
const svc = makeService({ sensitiveMediaDetectionApiUrl: null });
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
expect(res).toEqual([null, null]);
expect(sendMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => {
sendMock.mockResolvedValue(okResponse([
fetchMock.mockResolvedValue(okResponse([
{ success: true, predictions: neutral() },
{ success: true, predictions: neutral() },
{ success: true, predictions: neutral() },
@@ -127,24 +130,22 @@ describe('SensitiveMediaDetectionService', () => {
const svc = makeService({ sensitiveMediaDetectionMaxImagesPerRequest: 2 });
const res = await svc.detectSensitiveMany([buf('a'), buf('b'), buf('c'), buf('d'), buf('e')]);
// 5 枚を 2 枚ずつ → 3 リクエスト、結果は順序を保って 5 件。
expect(sendMock).toHaveBeenCalledTimes(3);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(res).toHaveLength(5);
expect(res.every(x => x != null)).toBe(true);
});
test('APIキー設定時のみ Authorization: Bearer を付与する', async () => {
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const withKey = makeService({ sensitiveMediaDetectionApiKey: 'secret' });
await withKey.detectSensitiveMany([buf('a')]);
const withKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(withKeyHeaders.Authorization).toBe('Bearer secret');
expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBe('Bearer secret');
sendMock.mockClear();
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
fetchMock.mockClear();
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const withoutKey = makeService();
await withoutKey.detectSensitiveMany([buf('a')]);
const withoutKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(withoutKeyHeaders.Authorization).toBeUndefined();
expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBeUndefined();
});
});
@@ -14,7 +14,7 @@ import { mockDeep } from 'vitest-mock-extended';
import { GlobalModule } from '@/GlobalModule.js';
import { FileInfo, FileInfoService } from '@/core/FileInfoService.js';
//import { DI } from '@/di-symbols.js';
import { SensitiveMediaDetectionService } from '@/core/SensitiveMediaDetectionService.js';
import { AiService } from '@/core/AiService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type { TestingModule } from '@nestjs/testing';
@@ -41,13 +41,13 @@ describe('FileInfoService', () => {
GlobalModule,
],
providers: [
SensitiveMediaDetectionService,
AiService,
LoggerService,
FileInfoService,
],
})
.useMocker((token) => {
//if (token === SensitiveMediaDetectionService) {
//if (token === AiService) {
// return { };
//}
if (typeof token === 'function') {
@@ -11,7 +11,7 @@ import { describe, expect, test, beforeAll, afterAll, afterEach } from 'vitest';
import sharp from 'sharp';
import { DataSource, type Repository } from 'typeorm';
import { initTestDb, randomString } from '../../utils.js';
import type { SensitiveMediaDetectionService } from '@/core/SensitiveMediaDetectionService.js';
import type { AiService } from '@/core/AiService.js';
import { DownloadService } from '@/core/DownloadService.js';
import { FileInfoService } from '@/core/FileInfoService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@@ -147,11 +147,11 @@ describe('FileServerService', () => {
driveFilesRepository = db.getRepository(MiDriveFile);
const loggerService = new LoggerService();
const sensitiveMediaDetectionService = {
const aiService = {
detectSensitive: async () => null,
detectSensitiveMany: async (sources: Buffer[]) => sources.map(() => null),
} as unknown as SensitiveMediaDetectionService;
const fileInfoService = new FileInfoService(sensitiveMediaDetectionService, loggerService);
} as unknown as AiService;
const fileInfoService = new FileInfoService(aiService, loggerService);
const httpRequestService = new HttpRequestService(config);
const downloadService = new DownloadService(config, httpRequestService, loggerService);
const imageProcessingService = new ImageProcessingService();
-8
View File
@@ -27,14 +27,6 @@ export function extractDomain(url: string) {
return match ? match[1] : null;
}
export function tryParseUrl(url: string | URL, base?: string | URL): URL | null {
try {
return new URL(url, base);
} catch {
return null;
}
}
export function maybeMakeRelative(urlStr: string, baseStr: string): string {
try {
const baseObj = new URL(baseStr);
@@ -81,10 +81,7 @@ const bitmapTmp = shallowRef<CanvasImageSource | undefined>();
watch([() => props.width, () => props.height, canvas], () => {
const ratio = props.width / props.height;
if (!Number.isFinite(ratio) || ratio <= 0) {
canvasWidth.value = 64;
canvasHeight.value = 64;
} else if (ratio > 1) {
if (ratio > 1) {
canvasWidth.value = Math.round(64 * ratio);
canvasHeight.value = 64;
} else {
@@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
key="canvas"
:class="$style.canvas"
:blurhash="hash ?? null"
:height="props.height"
:width="props.width"
:height="imgHeight ?? undefined"
:width="imgWidth ?? undefined"
:onlyAvgColor="props.onlyAvgColor"
:show="hide"
/>
+1 -1
View File
@@ -161,7 +161,7 @@ watch([changed, invalid], ([newChanged, newInvalid]) => {
}, { immediate: true });
// このコンポーネントが作成された時、非表示状態である場合がある
// 非表示状態だと要素の幅などは0になってしまうので、ResizeObserverでサイズの変化を監視して計算する
// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
const updatePadding = (entries: ResizeObserverEntry[]) => {
if (inputEl.value == null) return;
@@ -73,7 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="activated">
<img
v-if="content.type === 'image'"
:class="[$style.content, { [$style.pixelatedZoom]: pixelatedZoom }]"
:class="$style.content"
:src="content.url"
:alt="content.file?.comment ?? undefined"
draggable="false"
@@ -83,16 +83,16 @@ SPDX-License-Identifier: AGPL-3.0-only
v-else-if="content.type === 'video'"
ref="videoEl"
data-gallery-click-action="video"
:class="[$style.video, { [$style.videoSized]: videoAspectRatio != null }]"
:class="$style.content"
:src="content.url"
:alt="content.file?.comment ?? undefined"
draggable="false"
:controls="prefer.s.useNativeUiForVideoAudioPlayer"
playsinline
@loadedmetadata="onVideoLoadedMetadata"
@loadedmetadata="originalContentLoaded = true"
@click.stop="onVideoClick"
></video>
<div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isVideoPlaying" :class="$style.playIconWrapper">
<div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isVideoPlaying" data-gallery-click-action="video" :class="$style.playIconWrapper">
<div :class="$style.playIcon">
<i class="ti ti-player-play"></i>
</div>
@@ -208,10 +208,6 @@ const emit = defineEmits<{
(ev: 'cancelHorizontalSwipe'): void;
}>();
// 一回のビューワー操作内では状態を維持するためにmodelで親に伝えて親で状態を保持する
// TODO: drivefileのproperties側にピクセルアートかどうかのフラグを立ててそちらを元にデフォルトの挙動を決めるようにする
const pixelatedZoom = defineModel<boolean>('pixelatedZoom', { required: true });
const rootEl = useTemplateRef('rootEl');
const mainEl = useTemplateRef('mainEl');
const videoEl = useTemplateRef('videoEl');
@@ -228,21 +224,6 @@ const isVideoPlaying = computed(() => videoControl.value?.isPlaying ?? false);
const isVideoActuallyPlaying = computed(() => videoControl.value?.isActuallyPlaying ?? false);
let canOpenAnimation = false;
const videoAspectRatio = ref<number | null>(
props.content.width != null && props.content.height != null && props.content.width > 0 && props.content.height > 0
? props.content.width / props.content.height
: null
);
function onVideoLoadedMetadata() {
originalContentLoaded.value = true;
// ドライブ上のメタデータが無い場合に限り、動画自体の初期サイズから縦横比を確定させる
if (videoAspectRatio.value != null) return;
if (videoEl.value == null || videoEl.value.videoWidth === 0 || videoEl.value.videoHeight === 0) return;
videoAspectRatio.value = videoEl.value.videoWidth / videoEl.value.videoHeight;
}
const headerSize = 30;
const footerSize = props.content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer ? 80 : 0;
@@ -884,11 +865,6 @@ function openMenu(ev: PointerEvent) {
});
menu.push({
type: 'switch',
text: i18n.ts.pixelatedZoom,
icon: 'ti ti-grain',
ref: pixelatedZoom,
}, {
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
@@ -965,29 +941,6 @@ defineExpose({
object-fit: contain;
}
.pixelatedZoom {
image-rendering: pixelated;
}
.video {
display: block;
user-select: none;
position: absolute;
top: 50%;
left: 50%;
translate: -50% -50%;
width: 100%;
height: 100%;
object-fit: contain;
}
.videoSized {
width: min(100cqw, calc(100cqh * v-bind("videoAspectRatio ?? 16 / 9")));
height: auto;
background-color: #000;
aspect-ratio: v-bind("videoAspectRatio ?? 16 / 9");
}
.loading {
position: absolute;
top: 0;
@@ -1016,8 +969,6 @@ defineExpose({
position: relative;
width: 100%;
height: 100%;
// .videoSizedが使う100cqw / 100cqhの基準 (= paddingを除いた実際の表示領域)
container-type: size;
transition: scale 200ms ease, opacity 200ms ease !important;
}
@@ -1034,7 +985,6 @@ defineExpose({
height: 100%;
display: grid;
place-items: center;
pointer-events: none;
}
.playIcon {
@@ -1050,8 +1000,8 @@ defineExpose({
transition: scale 100ms ease;
}
// アイコン自体はクリックを受け取らないので、hoverは下のvideo要素を経由して拾う
.video:hover ~ .playIconWrapper .playIcon {
.playIconWrapper:hover .playIcon,
.playIcon:hover {
scale: 1.2;
}
@@ -28,7 +28,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-for="(content, i) in contents" :key="content.url" ref="itemEl" :class="$style.item">
<XItem
:ref="(comp) => { items.set(i, comp as InstanceType<typeof XItem>); }"
v-model:pixelatedZoom="pixelatedZoom"
:content="content"
:initiallyOpened="i === (props.defaultIndex ?? 0)"
:activated="activatedIndexes.has(i)"
@@ -73,8 +72,6 @@ const activatedIndexes = ref(new Set<number>());
const items = new Map<number, InstanceType<typeof XItem> | null>();
const currentIndex = ref(props.defaultIndex ?? 0);
const pixelatedZoom = ref(false);
watch(currentIndex, (newIndex, oldIndex) => {
activatedIndexes.value.add(newIndex);
+1 -1
View File
@@ -357,7 +357,7 @@ const keymap = {
renoteCollapsed.value = false;
} else if (appearNote.cw != null) {
showContent.value = !showContent.value;
} else if (isLong) {
} else if (isLong.value) {
collapsed.value = !collapsed.value;
}
},
@@ -109,7 +109,7 @@ const height =
const focus = () => container.value?.focus();
// このコンポーネントが作成された時、非表示状態である場合がある
// 非表示状態だと要素の幅などは0になってしまうので、ResizeObserverでサイズの変化を監視して計算する
// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
const updatePadding = (entries: ResizeObserverEntry[]) => {
if (inputEl.value == null) return;
+39 -39
View File
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ref } from 'vue';
import { ref, computed } from 'vue';
import type { Ref } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
@@ -62,34 +62,36 @@ export interface UseNoteOptions {
currentAntenna?: Ref<Misskey.entities.Antenna | null> | null;
}
export function checkNoteWordMute(
export function calculateMuteStatus<
CheckOnly extends boolean,
CheckForSensitiveMedia extends boolean,
ReturnTypeA = CheckOnly extends true ? boolean : Array<string | string[]> | false | 'sensitiveMute',
ReturnTypeB = CheckForSensitiveMedia extends true ? ReturnTypeA : Exclude<ReturnTypeA, 'sensitiveMute'>,
>(
noteToCheck: Misskey.entities.Note,
user: typeof $i,
mutedWords: Array<string | string[]> | null,
): Array<string | string[]> | false {
checkForSensitiveMedia: CheckForSensitiveMedia,
checkOnly: CheckOnly = false as CheckOnly,
): ReturnTypeB {
if (mutedWords != null) {
const result = checkWordMute(noteToCheck, user, mutedWords);
if (Array.isArray(result)) return result;
if (Array.isArray(result)) return checkOnly ? (result.length > 0) as ReturnTypeB : result as ReturnTypeB;
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, user, mutedWords);
if (Array.isArray(replyResult)) return replyResult;
if (Array.isArray(replyResult)) return checkOnly ? (replyResult.length > 0) as ReturnTypeB : replyResult as ReturnTypeB;
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, user, mutedWords);
if (Array.isArray(renoteResult)) return renoteResult;
if (Array.isArray(renoteResult)) return checkOnly ? (renoteResult.length > 0) as ReturnTypeB : renoteResult as ReturnTypeB;
}
return false;
}
if (checkOnly) return false as ReturnTypeB;
export function checkBuiltinSoftMute(
noteToCheck: Misskey.entities.Note,
checkForSensitiveMedia: boolean,
): 'sensitiveMute' | false {
if (checkForSensitiveMedia && noteToCheck.files?.some((v) => v.isSensitive)) {
return 'sensitiveMute' as never;
return 'sensitiveMute' as ReturnTypeB;
}
return false;
return false as ReturnTypeB;
}
/** MkNote, MkNoteDetailedの共通ロジック */
@@ -106,7 +108,7 @@ export function useNote(
// プラグインの割り込み処理
let rawNote = deepClone(props.note);
let hideByPlugin = false;
const hideByPlugin = ref(false);
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
if (noteViewInterruptors.length > 0) {
@@ -119,7 +121,7 @@ export function useNote(
}
}
if (result == null) {
hideByPlugin = true;
hideByPlugin.value = true;
} else {
rawNote = result as Misskey.entities.Note;
}
@@ -143,25 +145,23 @@ export function useNote(
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
// ミュート判定
// mutedはミュート解除の操作で書き換わるのでrefだが、hardMutedは解除できないのでリアクティブにしない
const muted = ref($i ? checkNoteWordMute(appearNote, $i, $i.mutedWords) || checkBuiltinSoftMute(appearNote, inTimeline && !tl_withSensitive.value) : false);
const hardMuted = props.withHardMute && $i ? checkNoteWordMute(appearNote, $i, $i.hardMutedWords) : false;
const muted = ref($i ? calculateMuteStatus(appearNote, $i, $i.mutedWords, inTimeline && !tl_withSensitive.value) : false);
const hardMuted = ref(props.withHardMute && $i ? calculateMuteStatus(appearNote, $i, $i.hardMutedWords, inTimeline && !tl_withSensitive.value, true) : false);
// 導出値
// rawNote / appearNote / $i.id / prefer.s は変化しないので一度だけ計算する
const isMyRenote = $i != null && ($i.id === rawNote.userId);
const parsed = appearNote.text ? mfm.parse(appearNote.text) : null;
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null;
const isLong = shouldCollapsed(appearNote, urls ?? []);
const collapsed = ref(appearNote.cw == null && isLong);
const canRenote = ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
// 計算プロパティ (Computed)
const isMyRenote = computed(() => $i && ($i.id === rawNote.userId));
const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null);
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null);
const isLong = computed(() => shouldCollapsed(appearNote, urls.value ?? []));
const collapsed = ref(appearNote.cw == null && isLong.value);
const showTicker = computed(() => (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance));
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id));
const renoteCollapsed = ref(prefer.s.collapseRenotes && isRenote && (($i && ($i.id === rawNote.userId || $i.id === appearNote.userId)) || ($appearNote.myReaction != null)));
const pleaseLoginContext: OpenOnRemoteOptions = {
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${appearNote.id}`,
};
}));
// グローバルイベントの監視
useGlobalEvent('noteDeleted', (noteId) => {
@@ -216,7 +216,7 @@ export function useNote(
// 共通アクション関数群
async function renote() {
if (props.mock) return;
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext });
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
showMovedDialog();
if (els.renoteButton == null) return;
@@ -231,7 +231,7 @@ export function useNote(
async function reply() {
if (props.mock) return;
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext });
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
os.post({
reply: appearNote,
@@ -241,8 +241,8 @@ export function useNote(
});
}
async function react(createReactionMock?: (reaction: string) => void) {
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext });
async function react(customCallback?: (reaction: string) => void) {
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
showMovedDialog();
@@ -276,7 +276,7 @@ export function useNote(
}
sound.playMisskeySfx('reaction');
if (props.mock) {
if (createReactionMock) createReactionMock(reaction);
if (customCallback) customCallback(reaction);
return;
}
misskeyApi('notes/reactions/create', {
@@ -294,7 +294,7 @@ export function useNote(
async function reactViaMfmEmoji(reaction: string) {
if (props.mock) return;
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext });
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
showMovedDialog();
sound.playMisskeySfx('reaction');
@@ -372,7 +372,7 @@ export function useNote(
async function showRenoteMenu() {
if (props.mock) return;
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext });
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
if (!isLoggedIn) return;
const getUnrenote = () => ({
@@ -403,7 +403,7 @@ export function useNote(
menuItems.push(getCopyNoteLinkMenu(rawNote, i18n.ts.copyLinkRenote));
menuItems.push({ type: 'divider' });
if (isMyRenote) {
if (isMyRenote.value) {
menuItems.push(getUnrenote());
os.popupMenu(menuItems, els.renoteTime?.value);
} else {
@@ -437,7 +437,7 @@ export function useNote(
collapsed,
renoteCollapsed,
// 導出値
// 計算プロパティ
isMyRenote,
parsed,
urls,
@@ -8,6 +8,7 @@ import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { prefer } from '@/preferences.js';
import { popup } from '@/os.js';
const handlers = new WeakMap<HTMLElement, (ev: MouseEvent) => void>();
const abortControllers = new WeakMap<HTMLElement, AbortController>();
export const rippleDirective = {
@@ -31,7 +31,6 @@ import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
import { url as baseUrl } from '@@/js/config.js';
import { tryParseUrl } from '@@/js/url.js';
import MkMarqueeText from '@/components/MkMarqueeText.vue';
import { shuffle } from '@/utility/shuffle.js';
@@ -57,8 +56,8 @@ const tick = () => {
}
items.value = feed.items.filter((item) => {
if (!item.link) return false;
const itemUrl = tryParseUrl(item.link, baseUrl);
return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
const itemUrl = new URL(item.link, baseUrl);
return ['http:', 'https:'].includes(itemUrl.protocol);
});
fetching.value = false;
key.value++;
+3 -4
View File
@@ -24,11 +24,10 @@ import { ref, watch, computed } from 'vue';
import * as Misskey from 'misskey-js';
import { url as base } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
import { tryParseUrl } from '@@/js/url.js';
import { useWidgetPropsManager } from './widget.js';
import { i18n } from '@/i18n.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import { i18n } from '@/i18n.js';
import MkContainer from '@/components/MkContainer.vue';
const name = 'rss';
@@ -84,8 +83,8 @@ const tick = () => {
.then((feed: Misskey.entities.FetchRssResponse) => {
rawItems.value = feed.items.filter((item) => {
if (!item.link) return false;
const itemUrl = tryParseUrl(item.link, base);
return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
const itemUrl = new URL(item.link, base);
return ['http:', 'https:'].includes(itemUrl.protocol);
});
fetching.value = false;
});
@@ -29,16 +29,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import * as Misskey from 'misskey-js';
import { url as base } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
import { tryParseUrl } from '@@/js/url.js';
import { useWidgetPropsManager } from './widget.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import MkMarqueeText from '@/components/MkMarqueeText.vue';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import MkContainer from '@/components/MkContainer.vue';
import { shuffle } from '@/utility/shuffle.js';
import { i18n } from '@/i18n.js';
import { url as base } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
const name = 'rssTicker';
@@ -124,8 +123,8 @@ const tick = () => {
.then((feed: Misskey.entities.FetchRssResponse) => {
rawItems.value = feed.items.filter((item) => {
if (!item.link) return false;
const itemUrl = tryParseUrl(item.link, base);
return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
const itemUrl = new URL(item.link, base);
return ['http:', 'https:'].includes(itemUrl.protocol);
});
fetching.value = false;
key.value++;
+1 -3
View File
@@ -85,9 +85,6 @@ test.describe('After setup instance', () => {
await visitHome(page);
await page.getByTestId('signup').click();
await page.getByTestId('signup-rules-continue').waitFor({ state: 'visible' });
test.expect(await page.getByTestId('signup-rules-continue').isDisabled()).toBeTruthy();
await locateMkSwitch(page, 'signup-rules-notes-agree').click();
await page.getByTestId('modal-dialog-ok').click();
test.expect(await page.getByTestId('signup-rules-continue').isDisabled()).toBeFalsy();
@@ -196,6 +193,7 @@ test.describe('After user setup', () => {
await registerUser('alice', 'alice1234');
await signIn(page, 'alice', 'alice1234');
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
await closeUserSetupDialog(page);
});
-1
View File
@@ -115,7 +115,6 @@ export async function signupThroughUi(
await signupResponse;
}
// 表示に時間がかかるのでPlaywrightのデフォルトのタイムアウトだと間に合わない
export async function closeUserSetupDialog(page: Page, timeout = 30_000): Promise<void> {
await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click({ timeout });
await page.getByTestId('modal-dialog-ok').click();
-21
View File
@@ -1,21 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test } from 'vitest';
import { tryParseUrl } from '@@/js/url.js';
describe('tryParseUrl', () => {
test('parses an absolute URL', () => {
expect(tryParseUrl('https://example.com/path')?.href).toBe('https://example.com/path');
});
test('parses a relative URL against a base URL', () => {
expect(tryParseUrl('/path', 'https://example.com/base')?.href).toBe('https://example.com/path');
});
test('returns null for an invalid URL', () => {
expect(tryParseUrl('https://[invalid')).toBeNull();
});
});
-4
View File
@@ -5699,10 +5699,6 @@ export interface Locale extends ILocale {
* AND指定になりOR指定になります
*/
"urlPreviewSensitiveListDescription": string;
/**
*
*/
"pixelatedZoom": string;
"_imageEditing": {
"_vars": {
/**
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2026.8.0-alpha.0",
"version": "2026.7.0-rc.0",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",
@@ -749,7 +749,7 @@ declare module '../api.js' {
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:admin:queue*
* **Credential required**: *Yes* / **Permission**: *read:admin:emoji*
*/
request<E extends 'admin/queue/stats', P extends Endpoints[E]['req']>(
endpoint: E,
+1 -1
View File
@@ -616,7 +616,7 @@ export type paths = {
* admin/queue/stats
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:admin:queue*
* **Credential required**: *Yes* / **Permission**: *read:admin:emoji*
*/
post: operations['admin___queue___stats'];
};
-261
View File
@@ -1,261 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// @ts-check
/**
* Misskey 変更の完了前検査
*
* 変更ファイル lintSPDX 自動修正と再検査locale safety を独立して実行し
* 最後に終了コードを集約する
*
* 使い方:
* node scripts/check-shipping.mjs
* node scripts/check-shipping.mjs --base <ref>
*
* 終了コード:
* 0 = 全検査に合格 (lint 対象なしの SKIPPED を含む)
* 1 = lint / SPDX / locale の違反
* 2 = 引数Git refコマンド起動などの理由で検査不能
*/
import { spawnSync } from 'node:child_process';
import { statSync } from 'node:fs';
import { join } from 'node:path';
import { DEFAULT_INTEGRATION_REFS, findClosestMergeBase, gitLines, gitMergeBase, gitPaths } from './lib/git.mjs';
const PNPM_COMMAND = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
const LINT_TARGETS = [
{ root: 'packages/backend', pattern: /^packages\/backend\/(src|test-federation)\/.*\.ts$/ },
{ root: 'packages/frontend', pattern: /^packages\/frontend\/src\/.*\.(ts|vue)$/ },
{ root: 'packages/frontend-embed', pattern: /^packages\/frontend-embed\/src\/.*\.(ts|vue)$/ },
{ root: 'packages/icons-subsetter', pattern: /^packages\/icons-subsetter\/src\/.*\.ts$/ },
{ root: 'packages/sw', pattern: /^packages\/sw\/src\/.*\.ts$/ },
...['frontend-shared', 'frontend-builder', 'i18n', 'misskey-js', 'misskey-bubble-game', 'misskey-reversi'].map((name) => ({
root: `packages/${name}`,
pattern: new RegExp(`^packages/${name}/.*\\.(js|jsx|ts|tsx)$`),
})),
...['changelog-checker', 'diagnostics-backend', 'diagnostics-frontend', 'diagnostics-shared'].map((name) => ({
root: `packages-private/${name}`,
pattern: new RegExp(`^packages-private/${name}/.*\\.(js|jsx|ts|tsx)$`),
})),
];
class OperationalError extends Error {}
/**
* 明示 ref または最も近い統合先から merge-base を選ぶ
*
* @param {string | null} explicitRef
* @returns {string}
*/
function resolveMergeBase(explicitRef) {
if (explicitRef !== null) return gitMergeBase(explicitRef);
try {
return findClosestMergeBase(DEFAULT_INTEGRATION_REFS);
} catch (error) {
const detail = error instanceof Error ? `${error.message}` : '';
throw new OperationalError(`統合先の merge-base を解決できない。--base <ref> または MISSKEY_BASE_REF を指定すること${detail}`);
}
}
/**
* commit 済み未commituntracked を含む変更ファイルを列挙する
*
* @param {string} base
* @returns {string[]}
*/
function listChangedFiles(base) {
return [...new Set([
...gitPaths(['diff', '--name-only', '-z', `${base}...HEAD`]),
...gitPaths(['diff', '--name-only', '-z', 'HEAD']),
...gitPaths(['ls-files', '--others', '--exclude-standard', '-z']),
])].sort();
}
/**
* パスが存在する通常ファイルかを判定する
*
* @param {string} file
* @returns {boolean}
*/
function isRegularFile(file) {
try {
return statSync(file).isFile();
} catch (error) {
if (/** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT') return false;
throw new OperationalError(`${file}: ファイル種別を確認できない`);
}
}
/**
* 指定ディレクトリでコマンドを実行し終了コードを返す
*
* @param {string} command
* @param {string[]} args
* @param {string} cwd
* @returns {number}
*/
function runCommand(command, args, cwd) {
const result = spawnSync(command, args, {
cwd,
stdio: 'inherit',
});
if (result.error !== undefined) {
throw new OperationalError(`${command} を起動できない: ${result.error.message}`);
}
if (result.status === null) {
throw new OperationalError(`${command} が signal ${result.signal ?? 'unknown'} で終了した`);
}
return result.status;
}
/**
* 子プロセスの終了コードを検査結果の 012 に正規化する
*
* @param {number} status
* @returns {0 | 1 | 2}
*/
function normalizeStatus(status) {
if (status === 0) return 0;
if (status === 1) return 1;
return 2;
}
/**
* 変更ファイルを package ごとに分け対象限定 ESLint を実行する
*
* @param {string[]} changedFiles
* @param {string} repoRoot
* @returns {{ status: 0 | 1 | 2, summary: 'PASS' | 'FAIL' | 'ERROR' | 'SKIPPED' }}
*/
function runChangedFileLint(changedFiles, repoRoot) {
let ran = false;
/** @type {0 | 1 | 2} */ let status = 0;
for (const target of LINT_TARGETS) {
try {
const files = changedFiles
.filter((file) => target.pattern.test(file) && isRegularFile(file))
.map((file) => file.slice(target.root.length + 1));
if (files.length === 0) continue;
ran = true;
console.log(`Lint: ${target.root} (${files.length} files)`);
const current = normalizeStatus(runCommand(PNPM_COMMAND, ['exec', 'eslint', '--quiet', '--', ...files], join(repoRoot, target.root)));
if (current > status) status = current;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Lint: 検査不能 — ${message}`);
status = 2;
}
}
if (!ran && status === 0) {
console.log('Lint: SKIPPED (対象ファイルなし)');
return { status: 0, summary: 'SKIPPED' };
}
return { status, summary: status === 0 ? 'PASS' : status === 1 ? 'FAIL' : 'ERROR' };
}
/**
* SPDX 検査を実行し違反時は対象内を修正して再検査する
*
* @param {string} base
* @param {string} repoRoot
* @returns {0 | 1 | 2}
*/
function runSpdx(base, repoRoot) {
try {
let status = normalizeStatus(runCommand(process.execPath, ['scripts/check-spdx.mjs'], repoRoot));
if (status !== 1) return status;
console.log('SPDX: 違反を検出。ローカル変更の欠落だけを自動修正する。');
const fixStatus = normalizeStatus(runCommand(process.execPath, ['scripts/check-spdx.mjs', '--fix', '--base', base], repoRoot));
if (fixStatus === 2) return 2;
status = normalizeStatus(runCommand(process.execPath, ['scripts/check-spdx.mjs'], repoRoot));
return status;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`SPDX: 検査不能 — ${message}`);
return 2;
}
}
/**
* ja-JP.yml 以外の locale YAML 変更がないかを検査する
*
* @param {string[]} changedFiles
* @returns {0 | 1}
*/
function runLocaleSafety(changedFiles) {
const badLocales = changedFiles.filter((file) => (
file.startsWith('locales/') &&
file.endsWith('.yml') &&
file !== 'locales/ja-JP.yml'
));
if (badLocales.length === 0) {
console.log('Locale safety: PASS');
return 0;
}
for (const file of badLocales) console.error(`forbidden locale change: ${file}`);
return 1;
}
/**
* CLI 引数または環境変数から基準 ref を取り出す
*
* @param {string[]} args
* @returns {string | null | undefined}
*/
function parseBaseRef(args) {
if (args.length === 0) return process.env.MISSKEY_BASE_REF || null;
if (args.length === 2 && args[0] === '--base' && args[1] !== '' && !args[1].startsWith('--')) return args[1];
return undefined;
}
/**
* lintSPDXlocale safety を実行し終了コードを集約する
*
* @returns {number}
*/
function main() {
const baseRef = parseBaseRef(process.argv.slice(2));
if (baseRef === undefined) {
console.error('usage: node scripts/check-shipping.mjs [--base <ref>]');
return 2;
}
try {
const repoRoot = gitLines(['rev-parse', '--show-toplevel'])[0];
if (repoRoot === undefined) throw new OperationalError('git リポジトリの外で実行された');
process.chdir(repoRoot);
const base = resolveMergeBase(baseRef);
const changedFiles = listChangedFiles(base);
const lint = runChangedFileLint(changedFiles, repoRoot);
const spdxStatus = runSpdx(base, repoRoot);
const localeStatus = runLocaleSafety(changedFiles);
console.log('');
console.log('Shipping checks:');
console.log(`Lint: ${lint.summary}`);
console.log(`SPDX: ${spdxStatus === 0 ? 'PASS' : spdxStatus === 1 ? 'FAIL' : 'ERROR'}`);
console.log(`Locale safety: ${localeStatus === 0 ? 'PASS' : 'FAIL'}`);
if (lint.status === 2 || spdxStatus === 2) return 2;
if (lint.status === 1 || spdxStatus === 1 || localeStatus === 1) return 1;
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Shipping checks: 検査不能 — ${message}`);
return 2;
}
}
process.exitCode = main();
-341
View File
@@ -1,341 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// @ts-check
/**
* Misskey SPDX ヘッダーの検査 / 自動修正
*
* 使い方:
* node scripts/check-spdx.mjs ローカル検査 (repo 全体 + HTML コメント形式)
* node scripts/check-spdx.mjs --check 同上 (明示形)
* node scripts/check-spdx.mjs --ci CI 互換検査 (現行の OR 判定を維持)
* node scripts/check-spdx.mjs --fix [--base REF] このブランチの欠落だけ自動修正して全体を再検査
*
* 終了コード:
* 0 = 選択したモードの検査に合格
* 1 = SPDX 欠落またはローカル検査でのコメント形式違反
* 2 = 引数Git refファイル列挙読取などの理由で検査不能
*
* CI 互換判定は現行 CI と同じく copyright 行または AGPL license 行のどちらか
* 一方が全文のどこかにあれば pass するこの判定はライセンス表記の完全性や正当性を
* 保証しないローカル検査は加えて .vue / .html のコメント形式を検査する
*
* --check / --ci の合否はブランチの所有権で絞らない--fix だけが統合先との差分を
* 使い自分の変更に含まれる欠落へ修正対象を限定する修正後は必ず全体を再検査する
*/
import { lstatSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, extname } from 'node:path';
import { DEFAULT_INTEGRATION_REFS, findClosestMergeBase, gitLines, gitMergeBase, gitPaths } from './lib/git.mjs';
/** CI の対象ディレクトリ。この配列を CI とローカル検査の両方が使う。 */
const TARGET_DIRECTORIES = [
'packages/backend/migration',
'packages/backend/src',
'packages/backend/test',
'packages/frontend-shared/@types',
'packages/frontend-shared/js',
'packages/frontend-builder',
'packages/frontend/.storybook',
'packages/frontend/@types',
'packages/frontend/lib',
'packages/frontend/public',
'packages/frontend/src',
'packages/frontend/test',
'packages/frontend-embed/@types',
'packages/frontend-embed/src',
'packages/icons-subsetter/src',
'packages/misskey-bubble-game/src',
'packages/misskey-reversi/src',
'packages/sw/src',
'scripts',
];
const TARGET_EXTENSIONS = new Set(['.cjs', '.html', '.js', '.mjs', '.scss', '.ts', '.vue']);
const EXCLUDED_CONFIG_EXTENSIONS = new Set(['.cjs', '.js', '.mjs', '.ts']);
const HTML_COMMENT_EXTENSIONS = new Set(['.vue', '.html']);
const COPYRIGHT_LINE = 'SPDX-FileCopyrightText: syuilo and misskey-project';
const LICENSE_LINE = 'SPDX-License-Identifier: AGPL-3.0-only';
const HTML_HEADER = `<!--\n${COPYRIGHT_LINE}\n${LICENSE_LINE}\n-->\n\n`;
const BLOCK_HEADER = `/*\n * ${COPYRIGHT_LINE}\n * ${LICENSE_LINE}\n */\n\n`;
/** @typedef {'check' | 'fix' | 'ci'} Mode */
/** @typedef {'missing' | 'wrong-form' | 'ok'} Verdict */
class OperationalError extends Error {}
/**
* パスが存在する通常ファイルかを判定する
*
* @param {string} file
* @returns {boolean}
*/
function isRegularFile(file) {
try {
return lstatSync(file).isFile();
} catch (error) {
if (/** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT') return false;
throw new OperationalError(`${file}: ファイル種別を確認できない`);
}
}
/**
* パスが SPDX 検査の対象条件を満たすかを判定する
*
* @param {string} file
* @returns {boolean}
*/
function isTargetPath(file) {
const ext = extname(file);
if (!TARGET_EXTENSIONS.has(ext)) return false;
const name = basename(file);
if (name.includes('eslint')) return false;
if (EXCLUDED_CONFIG_EXTENSIONS.has(ext) && name.endsWith(`.config${ext}`)) return false;
return isRegularFile(file);
}
/**
* Git index と必要に応じて untracked から SPDX 対象ファイルを列挙する
*
* @param {boolean} includeUntracked
* @returns {string[]}
*/
function listTargetFiles(includeUntracked) {
const args = ['ls-files', '--cached'];
if (includeUntracked) args.push('--others', '--exclude-standard');
args.push('-z', '--', ...TARGET_DIRECTORIES);
return [...new Set(gitPaths(args))].filter(isTargetPath);
}
/**
* 明示 ref または利用可能な統合先から merge-base を選ぶ
*
* @param {string | null} explicitRef
* @returns {string}
*/
function resolveMergeBase(explicitRef) {
if (explicitRef !== null) return gitMergeBase(explicitRef);
try {
return findClosestMergeBase(DEFAULT_INTEGRATION_REFS);
} catch (error) {
const detail = error instanceof Error ? `${error.message}` : '';
throw new OperationalError(`統合先の merge-base を解決できない。--base <ref> を指定すること${detail}`);
}
}
/**
* merge-base 以降の commit 済み未commituntracked の変更を集める
*
* @param {string | null} explicitRef
* @returns {Set<string>}
*/
function listLocalChanges(explicitRef) {
const base = resolveMergeBase(explicitRef);
return new Set([
...gitPaths(['diff', '--name-only', '--diff-filter=d', '-z', `${base}...HEAD`]),
...gitPaths(['diff', '--name-only', '--diff-filter=d', '-z', 'HEAD']),
...gitPaths(['ls-files', '--others', '--exclude-standard', '-z']),
]);
}
/**
* 指定位置が閉じた HTML コメントの内側かを判定する
*
* @param {string} text
* @param {number} index
* @returns {boolean}
*/
function isInsideHtmlComment(text, index) {
const before = text.slice(0, index);
const lastOpen = before.lastIndexOf('<!--');
if (lastOpen === -1) return false;
const close = text.indexOf('-->', lastOpen + '<!--'.length);
return close !== -1 && index < close;
}
/**
* ファイルの SPDX 行と HTML コメント形式を判定する
*
* @param {string} file
* @returns {Verdict}
*/
function judge(file) {
let text;
try {
text = readFileSync(file, 'utf8');
} catch {
throw new OperationalError(`${file}: 内容を読み取れない`);
}
const positions = [text.indexOf(COPYRIGHT_LINE), text.indexOf(LICENSE_LINE)].filter((index) => index >= 0);
if (positions.length === 0) return 'missing';
if (!HTML_COMMENT_EXTENSIONS.has(extname(file))) return 'ok';
return positions.every((position) => isInsideHtmlComment(text, position)) ? 'ok' : 'wrong-form';
}
/**
* ファイル種別と先頭指示行に合わせSPDX ヘッダーを適切な位置へ挿入する
*
* @param {string} file
* @returns {void}
*/
function insertHeader(file) {
const original = readFileSync(file, 'utf8');
const bom = original.charCodeAt(0) === 0xfeff ? '' : '';
const body = bom === '' ? original : original.slice(1);
const eol = body.includes('\r\n') ? '\r\n' : '\n';
/** ファイルの改行コードに合わせてヘッダーを変換する。 @param {string} header */
const withEol = (header) => (eol === '\n' ? header : header.replaceAll('\n', eol));
if (HTML_COMMENT_EXTENSIONS.has(extname(file))) {
writeFileSync(file, bom + withEol(HTML_HEADER) + body);
return;
}
const newlineIndex = body.indexOf('\n');
const firstLine = (newlineIndex === -1 ? body : body.slice(0, newlineIndex)).replace(/\r$/, '');
if (firstLine.startsWith('#!') || firstLine.startsWith('@charset')) {
const rest = newlineIndex === -1 ? '' : body.slice(newlineIndex + 1);
writeFileSync(file, bom + firstLine + eol + eol + withEol(BLOCK_HEADER) + rest);
return;
}
writeFileSync(file, bom + withEol(BLOCK_HEADER) + body);
}
/**
* CLI 引数を検証し実行モードと基準 ref を取り出す
*
* @param {string[]} args
* @returns {{ mode: Mode, baseRef: string | null } | null}
*/
function parseArgs(args) {
/** @type {Mode} */ let mode = 'check';
let explicitMode = false;
/** @type {string | null} */ let baseRef = null;
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg === '--check' || arg === '--fix' || arg === '--ci') {
if (explicitMode) return null;
mode = arg === '--check' ? 'check' : arg === '--fix' ? 'fix' : 'ci';
explicitMode = true;
continue;
}
if (arg === '--base') {
if (baseRef !== null || args[index + 1] === undefined || args[index + 1].startsWith('--')) return null;
baseRef = args[++index];
continue;
}
return null;
}
if (baseRef !== null && mode !== 'fix') return null;
return { mode, baseRef };
}
/**
* 対象ファイルを検査し違反種別ごとの一覧にまとめる
*
* @param {string[]} targets
* @returns {{ missing: string[], wrongForm: string[] }}
*/
function inspectTargets(targets) {
/** @type {string[]} */ const missing = [];
/** @type {string[]} */ const wrongForm = [];
for (const file of targets) {
const verdict = judge(file);
if (verdict === 'missing') missing.push(file);
if (verdict === 'wrong-form') wrongForm.push(file);
}
return { missing, wrongForm };
}
/**
* SPDX の検査修正を実行し集約した終了コードを返す
*
* @returns {number}
*/
function main() {
const options = parseArgs(process.argv.slice(2));
if (options === null) {
console.error('usage: node scripts/check-spdx.mjs [--check | --ci | --fix [--base <ref>]]');
return 2;
}
try {
const root = gitLines(['rev-parse', '--show-toplevel'])[0];
if (root === undefined) throw new OperationalError('git リポジトリの外で実行された');
process.chdir(root);
const includeUntracked = options.mode !== 'ci';
let targets = listTargetFiles(includeUntracked);
/** @type {string[]} */ const fixed = [];
if (options.mode === 'fix') {
const localChanges = listLocalChanges(options.baseRef);
const initial = inspectTargets(targets);
for (const file of initial.missing) {
if (!localChanges.has(file)) continue;
insertHeader(file);
fixed.push(file);
}
targets = listTargetFiles(true);
}
const result = inspectTargets(targets);
for (const file of fixed) console.log(`fixed: ${file}`);
for (const file of result.missing) console.log(`missing: ${file}`);
if (options.mode !== 'ci') {
for (const file of result.wrongForm) console.log(`wrong-form: ${file}`);
}
if (fixed.length > 0 || result.missing.length > 0 || (options.mode !== 'ci' && result.wrongForm.length > 0)) {
console.log('');
}
const failed = result.missing.length > 0 || (options.mode !== 'ci' && result.wrongForm.length > 0);
if (!failed) {
if (options.mode === 'ci') {
console.log(`SPDX CI: OK — 現行 OR 判定を対象 ${targets.length} ファイルが通過した。`);
} else {
const prefix = fixed.length > 0 ? `${fixed.length} ファイルを修正し、` : '';
console.log(`SPDX: OK — ${prefix}CI 判定と HTML コメント形式を対象 ${targets.length} ファイルが通過した。追加確認は不要。`);
}
return 0;
}
if (result.missing.length > 0) {
console.log(`SPDX: CI 判定上の欠落 ${result.missing.length} 件。`);
if (options.mode === 'check') {
console.log("SPDX: 統合先との差分に含まれる欠落は '--fix' で自動修正できる。");
} else if (options.mode === 'fix') {
console.log('SPDX: --fix の対象外を含む欠落が残っているため、CI は通らない。');
}
}
if (options.mode !== 'ci' && result.wrongForm.length > 0) {
console.log(`SPDX: HTML コメント形式違反 ${result.wrongForm.length} 件。`);
}
return 1;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`SPDX: 検査不能 — ${message}`);
return 2;
}
}
// process.exit() だと stdout が pipe のとき未フラッシュの出力が切り捨てられる。
process.exitCode = main();
-139
View File
@@ -1,139 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// @ts-check
import { execFileSync } from 'node:child_process';
import { TextDecoder } from 'node:util';
const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
export const DEFAULT_INTEGRATION_REFS = ['origin/develop', 'develop', 'origin/master', 'master'];
class GitOperationError extends Error {}
/**
* Git 子プロセスの終了状態と stderr を1行の診断情報にする
*
* @param {unknown} error
* @returns {string}
*/
function describeGitFailure(error) {
if (error === null || typeof error !== 'object') return '';
const failure = /** @type {{ status?: number | null, stderr?: Buffer | null }} */ (error);
const details = [];
if (typeof failure.status === 'number') details.push(`exit ${failure.status}`);
if (Buffer.isBuffer(failure.stderr)) {
const stderr = failure.stderr.toString('utf8').replace(/\s+/g, ' ').trim();
if (stderr !== '') details.push(stderr);
}
return details.join(': ');
}
/**
* Git コマンドを実行し標準出力を Buffer で返す
*
* @param {string[]} args
* @param {{ quiet?: boolean }} [options]
* @returns {Buffer}
*/
function gitBuffer(args, options = {}) {
try {
return execFileSync('git', args, {
maxBuffer: 64 * 1024 * 1024,
stdio: ['ignore', 'pipe', options.quiet === true ? 'pipe' : 'inherit'],
});
} catch (error) {
const detail = describeGitFailure(error);
throw new GitOperationError(`git ${args[0]} を実行できない${detail === '' ? '' : `${detail}`}`);
}
}
/**
* Git の標準出力を UTF-8 文字列へ変換する
*
* @param {Buffer} buffer
* @param {string} context
* @returns {string}
*/
function decodeGitOutput(buffer, context) {
try {
return UTF8_DECODER.decode(buffer);
} catch {
throw new GitOperationError(`${context}: Git 出力に UTF-8 でないファイル名が含まれる`);
}
}
/**
* Git の改行区切り出力を空行を除いた配列にする
*
* @param {string[]} args
* @param {{ quiet?: boolean }} [options]
* @returns {string[]}
*/
export function gitLines(args, options = {}) {
return decodeGitOutput(gitBuffer(args, options), `git ${args[0]}`)
.split('\n')
.filter((line) => line !== '');
}
/**
* Git NUL 区切りパス出力を空白改行を壊さず配列にする
*
* @param {string[]} args
* @param {{ quiet?: boolean }} [options]
* @returns {string[]}
*/
export function gitPaths(args, options = {}) {
return decodeGitOutput(gitBuffer(args, options), `git ${args[0]}`)
.split('\0')
.filter((path) => path !== '');
}
/**
* 指定した ref HEAD merge-base を取得する
*
* @param {string} ref
* @returns {string}
*/
export function gitMergeBase(ref) {
try {
const base = gitLines(['merge-base', ref, 'HEAD'], { quiet: true })[0];
if (base === undefined) throw new Error('empty merge-base');
return base;
} catch (error) {
const detail = error instanceof Error ? `${error.message}` : '';
throw new GitOperationError(`merge-base を解決できない: ${ref}${detail}`);
}
}
/**
* 利用可能な ref のうちHEAD に最も近い merge-base を返す
*
* @param {string[]} refs
* @returns {string}
*/
export function findClosestMergeBase(refs) {
/** @type {{ base: string, distance: number }[]} */
const candidates = [];
/** @type {string[]} */
const failures = [];
for (const ref of refs) {
try {
const base = gitMergeBase(ref);
const distanceText = gitLines(['rev-list', '--count', `${base}..HEAD`], { quiet: true })[0];
const distance = Number(distanceText);
if (!Number.isInteger(distance)) throw new GitOperationError(`HEAD までの距離を解釈できない: ${ref}`);
candidates.push({ base, distance });
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
failures.push(detail.includes(ref) ? detail : `${ref}: ${detail}`);
}
}
candidates.sort((a, b) => a.distance - b.distance);
if (candidates[0] !== undefined) return candidates[0].base;
throw new GitOperationError(`利用可能な統合先 ref がない (${failures.join('; ')})`);
}