@ryusenkei/conventions 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RYUSENKEI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,76 @@
1
- # Temporary Holding Version
1
+ # @ryusenkei/conventions
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shared conventions for RYUSENKEI monorepos created from monorepo-template. Rules that a tool can enforce are enforced by hooks and settings. Only the rest are written in `rules.md`.
4
+
5
+ | Part | What it does |
6
+ | --- | --- |
7
+ | [`rules.md`](rules.md) | Rules for Claude Code, imported from each repository's `CLAUDE.md` (Japanese) |
8
+ | `dist/hook.js` | Claude Code hook: blocks tool calls that break the rules, and optionally runs lint and tests before Claude finishes |
9
+ | `files/` | Master copies of the shared files, synced into each repository |
10
+ | `ryusenkei-conventions` | CLI: `sync` writes the shared files, `check` reports drift |
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ pnpm add -D @ryusenkei/conventions
16
+ ```
17
+
18
+ Requires Node.js 24 or later.
19
+
20
+ ## Setup
21
+
22
+ 1. Import the rules at the end of the repository's `CLAUDE.md`:
23
+
24
+ ```markdown
25
+ # CLAUDE.md
26
+
27
+ One-line summary of the repository.
28
+
29
+ ## このリポの規約
30
+
31
+ - Repository-specific rules
32
+
33
+ @node_modules/@ryusenkei/conventions/rules.md
34
+ ```
35
+
36
+ 2. Write the shared files: `npx ryusenkei-conventions sync` (add `--stop` to run lint and tests before Claude finishes).
37
+ 3. Add `ryusenkei-conventions check` to the `lint` script.
38
+
39
+ - Claude Code expands the import when a session starts. Run `pnpm install` first, or the rules are not loaded.
40
+ - The import resolves inside the working directory, so Claude Code does not ask for approval of external imports.
41
+
42
+ ## Enforced rules
43
+
44
+ The PreToolUse hook blocks the call and tells Claude why, so Claude can fix it and retry.
45
+
46
+ | Rule | Enforcement |
47
+ | --- | --- |
48
+ | pnpm only | Blocks `npm install` (and other npm changes) and `yarn` |
49
+ | Branch names `<type>/<name>` | Checks `git switch -c`, `git checkout -b`, `git branch`, `git worktree add -b` |
50
+ | No skipped Git hooks | Blocks `git commit --no-verify` / `-n` and `git push --no-verify` |
51
+ | PR title = squash commit message | Checks `gh pr create` / `gh pr edit` titles with prose-lint's commit rules |
52
+ | No AI signature | `attribution` is empty, and the hook blocks PR bodies with an AI signature |
53
+ | Squash merge only | Blocks `gh pr merge --merge` / `--rebase` |
54
+ | Do not edit `node_modules` | Blocks Edit and Write under `node_modules` |
55
+ | Secrets | `permissions.deny` blocks reading `.env` and `.dev.vars` with Claude's file tools (not Bash) |
56
+ | Definition of done (`--stop`) | The Stop hook runs `pnpm lint` and `pnpm test` when the working tree has changes, and sends Claude back once on failure |
57
+
58
+ Commit messages are checked by `.githooks/commit-msg`, and edited files by prose-lint's PostToolUse hook.
59
+
60
+ ## Synced files
61
+
62
+ `sync` writes these files and `check` compares them, ignoring line endings:
63
+
64
+ - `.editorconfig`, `.gitattributes`, `.gitignore`, `.gitleaks.toml`, `mise.toml`, `.github/dependabot.yaml`, `.githooks/commit-msg`
65
+ - `.claude/settings.json`: only the hooks, `attribution`, and `permissions.deny` are managed. Other settings and hooks are kept
66
+ - `pnpm-workspace.yaml`: `minimumReleaseAge` and `minimumReleaseAgeExclude`, matching the Dependabot `cooldown`
67
+
68
+ `check` also reports a missing `rules.md` import in `CLAUDE.md`.
69
+
70
+ Updates arrive as Dependabot PRs. The repository's `cooldown` excludes `@ryusenkei/*`, so new versions are picked up the next day. After an update, `check` fails until `sync` is run.
71
+
72
+ ## Versioning
73
+
74
+ - major: a rule is removed or reversed, or a synced file changes incompatibly
75
+ - minor: a rule, a hook check, or a synced file is added
76
+ - patch: fixes and wording
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ // 共通の規約の CLI(リポのルートで実行)
3
+ // ryusenkei-conventions check マスタとの差分の検査(差分あり → 終了コード1)
4
+ // ryusenkei-conventions sync [--stop] マスタに合わせて書き込み
5
+ // --stop: 完了前の `pnpm lint`・`pnpm test` を Stop の hook に登録(省略時は現在の登録を維持)
6
+ import { execFileSync } from 'node:child_process';
7
+ import { parseArgs } from 'node:util';
8
+ import { drifts, sync } from "./files.js";
9
+ const { values, positionals } = parseArgs({ allowPositionals: true, options: { stop: { type: 'boolean' } } });
10
+ const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim();
11
+ const options = values.stop === undefined ? {} : { stop: values.stop };
12
+ switch (positionals[0]) {
13
+ case 'check': {
14
+ const found = drifts(root, options);
15
+ for (const d of found)
16
+ console.log(`${d.path}: ${d.message}`);
17
+ if (found.some((d) => d.expected !== undefined))
18
+ console.log('→ `ryusenkei-conventions sync` で修正');
19
+ process.exitCode = found.length ? 1 : 0;
20
+ break;
21
+ }
22
+ case 'sync': {
23
+ const rest = sync(root, options);
24
+ for (const d of rest)
25
+ console.log(`${d.path}: ${d.message}`);
26
+ process.exitCode = rest.length ? 1 : 0;
27
+ break;
28
+ }
29
+ default:
30
+ console.error('usage: ryusenkei-conventions <check|sync> [--stop]');
31
+ process.exitCode = 2;
32
+ }
@@ -0,0 +1,16 @@
1
+ /** CLAUDE.md で共通の規約を読み込む import */
2
+ export declare const IMPORT = "@node_modules/@ryusenkei/conventions/rules.md";
3
+ /** 差分のあるファイル(`expected` が `undefined` = 自動で修正できない → `message` に対処) */
4
+ export type Drift = {
5
+ path: string;
6
+ expected?: string;
7
+ message: string;
8
+ };
9
+ /** マスタとの差分 */
10
+ export declare function drifts(root: string, options?: {
11
+ stop?: boolean;
12
+ }): Drift[];
13
+ /** 自動で修正できる差分を書き込み、残りの差分を返却 */
14
+ export declare function sync(root: string, options?: {
15
+ stop?: boolean;
16
+ }): Drift[];
package/dist/files.js ADDED
@@ -0,0 +1,70 @@
1
+ // 利用側のリポにある共通ファイルの同期・検査
2
+ // - マスタ: パッケージの `files/`(npm は `.gitignore` を同梱しない → 先頭の `.` を除いた名前で格納)
3
+ // - 比較は改行コードを LF に正規化(Windows の作業ツリーは CRLF になりうる)
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { hasStop, managedSettings } from "./settings.js";
7
+ const FILES = [
8
+ { from: 'editorconfig', to: '.editorconfig' },
9
+ { from: 'gitattributes', to: '.gitattributes' },
10
+ { from: 'gitignore', to: '.gitignore' },
11
+ { from: 'gitleaks.toml', to: '.gitleaks.toml' },
12
+ { from: 'mise.toml', to: 'mise.toml' },
13
+ { from: 'dependabot.yaml', to: '.github/dependabot.yaml' },
14
+ { from: 'commit-msg', to: '.githooks/commit-msg', mode: 0o755 },
15
+ ];
16
+ const SETTINGS = '.claude/settings.json';
17
+ const WORKSPACE = 'pnpm-workspace.yaml';
18
+ /** CLAUDE.md で共通の規約を読み込む import */
19
+ export const IMPORT = '@node_modules/@ryusenkei/conventions/rules.md';
20
+ // `.github/dependabot.yaml` の `cooldown` と同値
21
+ const RELEASE_AGE = [
22
+ '# 公開から2日未満のバージョンは解決しない(`.github/dependabot.yaml` の `cooldown` と同値)',
23
+ 'minimumReleaseAge: 2880',
24
+ 'minimumReleaseAgeExclude:',
25
+ ' - "@ryusenkei/*"',
26
+ ];
27
+ const lf = (s) => s.replaceAll('\r\n', '\n');
28
+ const read = (path) => (existsSync(path) ? lf(readFileSync(path, 'utf8')) : undefined);
29
+ /** マスタとの差分 */
30
+ export function drifts(root, options = {}) {
31
+ const out = [];
32
+ for (const f of FILES) {
33
+ const expected = lf(readFileSync(new URL(`../files/${f.from}`, import.meta.url), 'utf8'));
34
+ if (read(join(root, f.to)) !== expected)
35
+ out.push({ path: f.to, expected, message: 'マスタと不一致' });
36
+ }
37
+ const raw = read(join(root, SETTINGS));
38
+ const current = raw === undefined ? {} : JSON.parse(raw);
39
+ const settings = `${JSON.stringify(managedSettings(current, { stop: options.stop ?? hasStop(current) }), null, 2)}\n`;
40
+ if (raw !== settings)
41
+ out.push({ path: SETTINGS, expected: settings, message: 'hook・`attribution`・`permissions.deny` が不一致' });
42
+ const workspace = read(join(root, WORKSPACE)) ?? '';
43
+ if (!workspace.includes(RELEASE_AGE.slice(1).join('\n'))) {
44
+ if (/^minimumReleaseAge/m.test(workspace))
45
+ out.push({ path: WORKSPACE, message: `\`minimumReleaseAge\`・\`minimumReleaseAgeExclude\` を次の値に修正\n${RELEASE_AGE.join('\n')}` });
46
+ else
47
+ out.push({ path: WORKSPACE, expected: `${workspace.trimEnd()}\n\n${RELEASE_AGE.join('\n')}\n`, message: '`minimumReleaseAge` が未設定' });
48
+ }
49
+ const claude = read(join(root, 'CLAUDE.md')) ?? '';
50
+ if (!claude.split('\n').some((l) => l.trim() === IMPORT))
51
+ out.push({ path: 'CLAUDE.md', message: `共通の規約の import(\`${IMPORT}\`)を追加` });
52
+ return out;
53
+ }
54
+ /** 自動で修正できる差分を書き込み、残りの差分を返却 */
55
+ export function sync(root, options = {}) {
56
+ const rest = [];
57
+ for (const d of drifts(root, options)) {
58
+ if (d.expected === undefined) {
59
+ rest.push(d);
60
+ continue;
61
+ }
62
+ const path = join(root, d.path);
63
+ mkdirSync(dirname(path), { recursive: true });
64
+ writeFileSync(path, d.expected);
65
+ const mode = FILES.find((f) => f.to === d.path)?.mode;
66
+ if (mode)
67
+ chmodSync(path, mode);
68
+ }
69
+ return rest;
70
+ }
package/dist/hook.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/hook.js ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ // Claude Code の hook(`.claude/settings.json` から `node` で直接実行)
3
+ // - PreToolUse: 規約に反するツールの実行を拒否 → 終了コード2・理由は stderr
4
+ // - Stop: 変更があれば `pnpm lint`・`pnpm test` を実行し、失敗時は完了を差し戻し
5
+ // - hook 自体の失敗は終了コード1(拒否にしない → 作業を継続)
6
+ import { spawnSync } from 'node:child_process';
7
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { denyReason } from "./policy.js";
12
+ const input = JSON.parse(readFileSync(0, 'utf8'));
13
+ const root = process.env.CLAUDE_PROJECT_DIR ?? input.cwd ?? process.cwd();
14
+ // Windows の `pnpm` は `.cmd` → シェル経由でのみ起動可能
15
+ const shell = process.platform === 'win32';
16
+ if (input.hook_event_name === 'PreToolUse') {
17
+ const reason = denyReason(input, { titleIssues, readFile });
18
+ if (reason) {
19
+ process.stderr.write(`${reason}\n`);
20
+ process.exit(2);
21
+ }
22
+ }
23
+ else if (input.hook_event_name === 'Stop') {
24
+ stop();
25
+ }
26
+ // PR のタイトル = squash のコミットのメッセージ → prose-lint のコミットの規則で検査(リポの `prose-lint.json` を反映)
27
+ function titleIssues(title) {
28
+ const dir = mkdtempSync(join(tmpdir(), 'conventions-'));
29
+ try {
30
+ const file = join(dir, 'title');
31
+ writeFileSync(file, `${title}\n`);
32
+ const cli = fileURLToPath(new URL('./cli.js', import.meta.resolve('@ryusenkei/prose-lint')));
33
+ const r = spawnSync(process.execPath, [cli, '--commit-msg', file], { cwd: root, encoding: 'utf8' });
34
+ return r.status === 0 ? [] : [`${r.stdout}${r.stderr}`.trim()];
35
+ }
36
+ finally {
37
+ rmSync(dir, { recursive: true, force: true });
38
+ }
39
+ }
40
+ function readFile(path) {
41
+ try {
42
+ return readFileSync(path, 'utf8');
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ // 差し戻しは1回まで(`stop_hook_active` = 差し戻し後の再実行)→ 解消できない失敗で停止しない状態を防止
49
+ function stop() {
50
+ if (input.stop_hook_active)
51
+ return;
52
+ const status = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' });
53
+ if (!status.stdout?.trim())
54
+ return;
55
+ for (const script of ['lint', 'test']) {
56
+ const r = spawnSync('pnpm', [script], { cwd: root, encoding: 'utf8', shell });
57
+ if (r.status === 0)
58
+ continue;
59
+ const tail = `${r.stdout}${r.stderr}`.trim().split('\n').slice(-40).join('\n');
60
+ console.log(JSON.stringify({ decision: 'block', reason: `\`pnpm ${script}\` が失敗。修正してから完了\n${tail}` }));
61
+ return;
62
+ }
63
+ }
@@ -0,0 +1 @@
1
+ export { drifts, sync, IMPORT, type Drift } from './files.ts';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { drifts, sync, IMPORT } from "./files.js";
@@ -0,0 +1,15 @@
1
+ /** PreToolUse の入力(使用する項目のみ) */
2
+ export type ToolUse = {
3
+ tool_name: string;
4
+ tool_input: Record<string, unknown>;
5
+ cwd?: string;
6
+ };
7
+ /** 判定に必要な外部の処理 */
8
+ export type Context = {
9
+ /** PR のタイトルの指摘(なければ空配列) */
10
+ titleIssues: (title: string) => string[];
11
+ /** ファイルの内容(読み込みの失敗時は `undefined`) */
12
+ readFile: (path: string) => string | undefined;
13
+ };
14
+ /** 拒否の理由(許可する場合は `undefined`) */
15
+ export declare function denyReason(use: ToolUse, ctx: Context): string | undefined;
package/dist/policy.js ADDED
@@ -0,0 +1,93 @@
1
+ // PreToolUse で拒否するツールの実行
2
+ // - 判定は純粋な関数 → 外部の処理(タイトルの検査・ファイルの読み込み)は `Context` で注入
3
+ // - 拒否の理由は Claude に返却 → Claude が修正して再実行
4
+ import { isAbsolute, join } from 'node:path';
5
+ import { commands, option } from "./shell.js";
6
+ const TYPES = ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test'];
7
+ const BRANCH = new RegExp(`^(${TYPES.join('|')})/[a-z0-9][a-z0-9._-]*$`);
8
+ const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'];
9
+ // pnpm 以外のパッケージ管理の操作(`npm view`・`npm pack`・`npx` などの参照・実行は対象外)
10
+ const NPM_CHANGES = ['install', 'i', 'add', 'ci', 'uninstall', 'un', 'remove', 'rm', 'update', 'up', 'link'];
11
+ // AI の署名: trailer の値に AI の名前・生成元の表記(prose-lint のコミットの規則と同じ対象)
12
+ const AI_TRAILER = /^[\w-]+:.*\b(claude|anthropic|openai|chatgpt|gpt-\d|copilot|gemini|codex|cursor)\b/im;
13
+ const AI_NOTE = /generated (with|by) \[?(claude|chatgpt|copilot|gemini|codex|cursor)|🤖/i;
14
+ // `git branch` の作成以外の操作
15
+ const BRANCH_QUERY = ['-d', '-D', '--delete', '-l', '--list', '-a', '-r', '--all', '--remotes', '-v', '-vv', '--verbose', '--show-current', '--contains', '--no-contains', '--merged', '--no-merged', '-u', '--set-upstream-to', '--unset-upstream', '--edit-description'];
16
+ /** 拒否の理由(許可する場合は `undefined`) */
17
+ export function denyReason(use, ctx) {
18
+ if (EDIT_TOOLS.includes(use.tool_name)) {
19
+ const path = String(use.tool_input.file_path ?? use.tool_input.notebook_path ?? '');
20
+ if (/(^|[\\/])node_modules[\\/]/.test(path))
21
+ return '`node_modules` は直接編集しない。`@ryusenkei/*` の変更は toolkit のリポで実施';
22
+ return undefined;
23
+ }
24
+ if (use.tool_name !== 'Bash')
25
+ return undefined;
26
+ const reasons = commands(String(use.tool_input.command ?? '')).flatMap((words) => commandReasons(words, use.cwd, ctx));
27
+ return reasons.length ? reasons.join('\n') : undefined;
28
+ }
29
+ function commandReasons(words, cwd, ctx) {
30
+ const [cmd, ...args] = words;
31
+ if (cmd === 'yarn' || (cmd === 'npm' && NPM_CHANGES.includes(args[0] ?? '')))
32
+ return ['パッケージ管理は pnpm のみ(`pnpm add`・`pnpm install` など)'];
33
+ if (cmd === 'git')
34
+ return gitReasons(gitArgs(args));
35
+ if (cmd === 'gh' && args[0] === 'pr')
36
+ return prReasons(args[1], args.slice(2), cwd, ctx);
37
+ return [];
38
+ }
39
+ // git の全体のオプション(`-C <path>`・`-c <name=value>` など)を除去
40
+ function gitArgs(args) {
41
+ let i = 0;
42
+ while (i < args.length && args[i].startsWith('-'))
43
+ i += args[i] === '-C' || args[i] === '-c' ? 2 : 1;
44
+ return args.slice(i);
45
+ }
46
+ function gitReasons([sub, ...args]) {
47
+ if ((sub === 'commit' || sub === 'push') && args.includes('--no-verify'))
48
+ return ['フックの省略(`--no-verify`)は禁止'];
49
+ // `git commit -n` = `--no-verify`(`git push -n` は dry run)
50
+ if (sub === 'commit' && args.some((a) => /^-[a-zA-Z]*n[a-zA-Z]*$/.test(a)))
51
+ return ['フックの省略(`-n`)は禁止'];
52
+ const name = newBranch(sub, args);
53
+ if (name !== undefined && !BRANCH.test(name))
54
+ return [`ブランチ名は \`<type>/<内容>\`(type = ${TYPES.join('・')}、内容 = 英小文字・数字・\`-\`): \`${name}\``];
55
+ return [];
56
+ }
57
+ // 作成・リネームするブランチの名前
58
+ function newBranch(sub, args) {
59
+ const positionals = args.filter((a) => !a.startsWith('-'));
60
+ switch (sub) {
61
+ case 'switch':
62
+ return option(args, ['-c', '-C', '--create', '--force-create']);
63
+ case 'checkout':
64
+ return option(args, ['-b', '-B']);
65
+ case 'worktree':
66
+ return args[0] === 'add' ? option(args, ['-b', '-B']) : undefined;
67
+ case 'branch':
68
+ if (args.some((a) => BRANCH_QUERY.includes(a)))
69
+ return undefined;
70
+ if (args.some((a) => ['-m', '-M', '--move', '-c', '-C', '--copy'].includes(a)))
71
+ return positionals.at(-1);
72
+ return positionals[0];
73
+ default:
74
+ return undefined;
75
+ }
76
+ }
77
+ function prReasons(sub, args, cwd, ctx) {
78
+ if (sub === 'merge' && args.some((a) => a === '--merge' || a === '--rebase' || a === '-m' || a === '-r'))
79
+ return ['マージは squash のみ(`--squash`)'];
80
+ if (sub !== 'create' && sub !== 'edit')
81
+ return [];
82
+ const reasons = [];
83
+ const title = option(args, ['--title', '-t']);
84
+ if (title === undefined && sub === 'create')
85
+ reasons.push('PR のタイトルを `--title` で指定(`main` のコミットのメッセージになるため検査)');
86
+ if (title !== undefined)
87
+ reasons.push(...ctx.titleIssues(title).map((i) => `PR のタイトル: ${i}`));
88
+ const file = option(args, ['--body-file', '-F']);
89
+ const body = [option(args, ['--body', '-b']), file && file !== '-' ? ctx.readFile(isAbsolute(file) || !cwd ? file : join(cwd, file)) : undefined].join('\n');
90
+ if (AI_TRAILER.test(body) || AI_NOTE.test(body))
91
+ reasons.push('PR の本文に AI の署名を付けない');
92
+ return reasons;
93
+ }
@@ -0,0 +1,27 @@
1
+ type Hook = {
2
+ type: string;
3
+ command: string;
4
+ args?: string[];
5
+ };
6
+ type Group = {
7
+ matcher?: string;
8
+ hooks: Hook[];
9
+ };
10
+ /** `.claude/settings.json`(使用する項目のみ型付け) */
11
+ export type Settings = {
12
+ hooks?: Record<string, Group[]>;
13
+ permissions?: {
14
+ deny?: string[];
15
+ [key: string]: unknown;
16
+ };
17
+ [key: string]: unknown;
18
+ };
19
+ /** hook の本体のパス(`node` で直接実行 → Windows でもシェルを経由しない) */
20
+ export declare const HOOK_PATH = "${CLAUDE_PROJECT_DIR}/node_modules/@ryusenkei/conventions/dist/hook.js";
21
+ /** Stop の hook(完了前の lint・test)が登録済みか */
22
+ export declare const hasStop: (s: Settings) => boolean;
23
+ /** 管理する項目を適用した設定 */
24
+ export declare function managedSettings(current: Settings, options: {
25
+ stop: boolean;
26
+ }): Settings;
27
+ export {};
@@ -0,0 +1,32 @@
1
+ // `.claude/settings.json` の管理する項目
2
+ // - hook の登録・`attribution`・`permissions.deny`
3
+ // - リポ独自の設定・hook は維持
4
+ /** hook の本体のパス(`node` で直接実行 → Windows でもシェルを経由しない) */
5
+ export const HOOK_PATH = '${CLAUDE_PROJECT_DIR}/node_modules/@ryusenkei/conventions/dist/hook.js';
6
+ const HOOK = { type: 'command', command: 'node', args: [HOOK_PATH] };
7
+ const PROSE_LINT = { type: 'command', command: 'npx --no -- prose-lint --hook' };
8
+ // 秘密値のファイル(`.env.example` は対象外 → `.env.*` の指定は不可)
9
+ const DENY = ['Read(./**/.env)', 'Read(./**/.dev.vars)'];
10
+ const owned = (h) => (h.command === 'node' && h.args?.[0] === HOOK_PATH) || h.command.includes('prose-lint --hook');
11
+ /** Stop の hook(完了前の lint・test)が登録済みか */
12
+ export const hasStop = (s) => (s.hooks?.Stop ?? []).some((g) => g.hooks.some(owned));
13
+ /** 管理する項目を適用した設定 */
14
+ export function managedSettings(current, options) {
15
+ const hooks = {};
16
+ for (const [event, groups] of Object.entries(current.hooks ?? {})) {
17
+ const kept = groups.map((g) => ({ ...g, hooks: g.hooks.filter((h) => !owned(h)) })).filter((g) => g.hooks.length);
18
+ if (kept.length)
19
+ hooks[event] = kept;
20
+ }
21
+ hooks.PreToolUse = [...(hooks.PreToolUse ?? []), { matcher: 'Bash|Edit|Write|MultiEdit|NotebookEdit', hooks: [HOOK] }];
22
+ hooks.PostToolUse = [...(hooks.PostToolUse ?? []), { matcher: 'Edit|Write|MultiEdit', hooks: [PROSE_LINT] }];
23
+ if (options.stop)
24
+ hooks.Stop = [...(hooks.Stop ?? []), { hooks: [HOOK] }];
25
+ const deny = [...new Set([...(current.permissions?.deny ?? []), ...DENY])];
26
+ return {
27
+ ...current,
28
+ attribution: { commit: '', pr: '', sessionUrl: false },
29
+ permissions: { ...current.permissions, deny },
30
+ hooks,
31
+ };
32
+ }
@@ -0,0 +1,4 @@
1
+ /** コマンド文字列をコマンドごとの語の配列に分解 */
2
+ export declare function commands(line: string): string[][];
3
+ /** オプションの値(`--name value`・`--name=value`・`-n value`)→ 指定がなければ `undefined` */
4
+ export declare function option(words: string[], names: string[]): string | undefined;
package/dist/shell.js ADDED
@@ -0,0 +1,75 @@
1
+ // シェルのコマンド文字列の簡易な分解(hook の判定用)
2
+ // - 区切り: `&&`・`||`・`;`・`|`・`&`・改行 → コマンドごとの語の配列
3
+ // - 解釈: 引用符(`'`・`"`)とバックスラッシュ → 変数・コマンド置換は展開しない
4
+ // - 先頭の `NAME=value`(環境変数の指定)は除去
5
+ /** コマンド文字列をコマンドごとの語の配列に分解 */
6
+ export function commands(line) {
7
+ const out = [];
8
+ let words = [];
9
+ let word = '';
10
+ // 空の引用符(`""`)も1語
11
+ let has = false;
12
+ let quote;
13
+ const endWord = () => {
14
+ if (has)
15
+ words.push(word);
16
+ word = '';
17
+ has = false;
18
+ };
19
+ const endCommand = () => {
20
+ endWord();
21
+ const start = words.findIndex((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w));
22
+ if (start !== -1)
23
+ out.push(words.slice(start));
24
+ words = [];
25
+ };
26
+ for (let i = 0; i < line.length; i++) {
27
+ const c = line[i];
28
+ if (quote) {
29
+ if (c === quote)
30
+ quote = undefined;
31
+ else if (c === '\\' && quote === '"' && '"\\$`'.includes(line[i + 1] ?? ''))
32
+ word += line[++i];
33
+ else
34
+ word += c;
35
+ continue;
36
+ }
37
+ if (c === "'" || c === '"') {
38
+ quote = c;
39
+ has = true;
40
+ }
41
+ else if (c === '\\' && i + 1 < line.length) {
42
+ const next = line[++i];
43
+ if (next !== '\n') {
44
+ word += next;
45
+ has = true;
46
+ }
47
+ }
48
+ else if (c === ' ' || c === '\t') {
49
+ endWord();
50
+ }
51
+ else if (c === '\n' || c === ';' || c === '&' || c === '|') {
52
+ endCommand();
53
+ if ((c === '&' || c === '|') && line[i + 1] === c)
54
+ i++;
55
+ }
56
+ else {
57
+ word += c;
58
+ has = true;
59
+ }
60
+ }
61
+ endCommand();
62
+ return out;
63
+ }
64
+ /** オプションの値(`--name value`・`--name=value`・`-n value`)→ 指定がなければ `undefined` */
65
+ export function option(words, names) {
66
+ for (let i = 0; i < words.length; i++) {
67
+ const w = words[i];
68
+ if (names.includes(w))
69
+ return words[i + 1];
70
+ const long = names.find((n) => n.startsWith('--') && w.startsWith(`${n}=`));
71
+ if (long)
72
+ return w.slice(long.length + 1);
73
+ }
74
+ return undefined;
75
+ }
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ # コミットメッセージの文体の検査(規則 = @ryusenkei/prose-lint)
3
+ exec npx --no -- prose-lint --commit-msg "$1"
@@ -0,0 +1,40 @@
1
+ version: 2
2
+ updates:
3
+ # npm(pnpm ワークスペース)。ルートの1件で apps/*・packages/* を横断
4
+ - package-ecosystem: npm
5
+ directory: "/"
6
+ # 毎日(自社のパッケージの新しいバージョンをすぐ取り込むため)
7
+ schedule:
8
+ interval: daily
9
+ # 公開直後のバージョンは配布ファイルの反映待ちでインストールに失敗しうる → 2日待つ。自社のパッケージは待たない
10
+ cooldown:
11
+ default-days: 2
12
+ exclude:
13
+ - "@ryusenkei/*"
14
+ commit-message:
15
+ prefix: build
16
+ include: scope
17
+ groups:
18
+ # 自社のパッケージは単独の PR(先に一致したグループに入る)
19
+ ryusenkei:
20
+ patterns:
21
+ - "@ryusenkei/*"
22
+ # minor・patch は1つの PR にまとめる(major は個別の PR でレビュー)
23
+ minor-and-patch:
24
+ update-types:
25
+ - minor
26
+ - patch
27
+ open-pull-requests-limit: 10
28
+
29
+ # GitHub Actions(.github/workflows のアクション)
30
+ - package-ecosystem: github-actions
31
+ directory: "/"
32
+ schedule:
33
+ interval: weekly
34
+ commit-message:
35
+ prefix: ci
36
+ include: scope
37
+ groups:
38
+ actions:
39
+ patterns:
40
+ - "*"
@@ -0,0 +1,12 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ insert_final_newline = true
7
+ indent_style = space
8
+ indent_size = 2
9
+ trim_trailing_whitespace = true
10
+
11
+ [*.md]
12
+ trim_trailing_whitespace = false
@@ -0,0 +1,2 @@
1
+ # 改行は LF に統一(OS による差分・警告を防止。フックは CRLF だと sh で動かない)
2
+ * text=auto eol=lf
@@ -0,0 +1,46 @@
1
+ # リポ全体の除外。パッケージ・アプリ固有のものは各階層の .gitignore(apps/site・infra)
2
+
3
+ # ---- 依存 ----
4
+ node_modules/
5
+ .pnpm-store/
6
+
7
+ # ---- ビルド・生成物 ----
8
+ dist/
9
+ build/
10
+ coverage/
11
+ *.tsbuildinfo
12
+ .astro/
13
+ .wrangler/
14
+
15
+ # ---- 秘密値(コミット禁止。雛形の .env.example のみ追跡) ----
16
+ .env
17
+ .env.*
18
+ !.env.example
19
+ .dev.vars
20
+ .dev.vars.*
21
+ *.pem
22
+ *.key
23
+
24
+ # ---- ログ ----
25
+ *.log
26
+ npm-debug.log*
27
+ pnpm-debug.log*
28
+
29
+ # ---- OS ----
30
+ .DS_Store
31
+ Thumbs.db
32
+ desktop.ini
33
+
34
+ # ---- エディタ・IDE ----
35
+ .idea/
36
+ .vscode/
37
+ *.code-workspace
38
+ *.swp
39
+ *~
40
+
41
+ # ---- Claude Code(skills は追跡) ----
42
+ .claude/worktrees/
43
+ .claude/settings.local.json
44
+
45
+ # ---- 作業用の一時ファイル(一回限りのスクリプトなど) ----
46
+ .tmp/
@@ -0,0 +1,9 @@
1
+ # gitleaks の設定。既定の規則に加え、公開してよい既知の値を除外
2
+ [extend]
3
+ useDefault = true
4
+
5
+ [allowlist]
6
+ description = "公開してよい雛形(秘密値ではない)"
7
+ paths = [
8
+ '''.*\.example$''',
9
+ ]
@@ -0,0 +1,4 @@
1
+ # ツールのバージョン(pnpm は package.json の packageManager で固定)
2
+ [tools]
3
+ node = "24"
4
+ terraform = "1"
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@ryusenkei/conventions",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.2",
4
+ "description": "RYUSENKEI のモノリポの共通の規約(Claude Code の規約・hook、共通ファイルの同期)",
5
+ "license": "MIT",
6
+ "author": "RYUSENKEI",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ryusenkei-inc/toolkit.git",
10
+ "directory": "packages/conventions"
11
+ },
12
+ "homepage": "https://github.com/ryusenkei-inc/toolkit/tree/main/packages/conventions",
13
+ "type": "module",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "bin": {
22
+ "ryusenkei-conventions": "dist/cli.js"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "files",
27
+ "rules.md"
28
+ ],
29
+ "engines": {
30
+ "node": ">=24"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "provenance": true
35
+ },
36
+ "dependencies": {
37
+ "@ryusenkei/prose-lint": "^0.1.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^26.6.1",
41
+ "typescript": "^5.9.3"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc -p tsconfig.build.json",
45
+ "typecheck": "tsc --noEmit"
46
+ }
6
47
  }
package/rules.md ADDED
@@ -0,0 +1,41 @@
1
+ ## コード
2
+
3
+ - 構成: パッケージの責務・依存は README の構成の表に準拠
4
+ - 共有: リポ内は `packages/*`、リポ間は toolkit(npm の `@ryusenkei/*`)。`@ryusenkei/*` の変更は toolkit で実施
5
+ - エントリポイント: パッケージごとに `src/index.ts`。export は外部で使用するものだけ・他パッケージの再 export なし
6
+ - TypeScript: ビルドせず Node で直接実行(`node scripts/x.ts`)
7
+ - テスト: Vitest。対象と同じディレクトリに `*.test.ts`
8
+ - 子プロセス: `scripts/lib/exec.ts` の `exec`(Windows の `.cmd` に対応)
9
+ - 依存の更新: Dependabot の PR で実施
10
+
11
+ ## 設定・秘密値
12
+
13
+ - 秘密値: ルート `.env` に集約。配信は `pnpm env:push` に一本化
14
+ - `*.tfvars`・Terraform の state: コミット禁止
15
+ - `.env.example`: 変数の追加時は `scripts/env.ts` の `TARGETS` にも定義
16
+ - 環境変数を付けた実行: `node scripts/env.ts run NAME=value <cmd>`(`NAME=value cmd` は Windows で不可)
17
+ - YAML の拡張子: `.yaml`(pnpm のファイルと統一)
18
+ - 共通ファイル(`.editorconfig`・`.github/dependabot.yaml`・`.claude/settings.json` など): `@ryusenkei/conventions` のマスタから同期。直接編集しない
19
+ - 変更は toolkit で実施し、`ryusenkei-conventions sync` で反映
20
+
21
+ ## 記述内容(コード・コメント・ドキュメント共通)
22
+
23
+ - コメント: 非自明な理由・制約のみ。処理の内容はコードの名前で表現
24
+ - 変更の経緯: コメントに書かず PR に記載
25
+ - `export` する関数・型・定数: JSDoc
26
+ - 個人名: 記載禁止(コード・コメント・ドキュメント・設定値すべて)
27
+
28
+ ## 文体(Markdown・コメント共通)
29
+
30
+ - 散文より構造化(表・箇条書き・キー: 値)
31
+ - ドメインの概念・説明・表示文字列は日本語
32
+ - 文体の指摘(`prose-lint`)がドメインの用語に該当する場合: `prose-lint.json` の `allow`・`vocabulary` に追加
33
+
34
+ ## PR
35
+
36
+ - 本文: 背景・変更・検証
37
+
38
+ ## 完了の条件
39
+
40
+ - `pnpm lint`(型検査・prose-lint・共通ファイルの検査)と `pnpm test` が通過
41
+ - README の構成・コマンドの記述が変更と一致