@ryusenkei/conventions 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +75 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +32 -0
- package/dist/files.d.ts +16 -0
- package/dist/files.js +70 -0
- package/dist/hook.d.ts +2 -0
- package/dist/hook.js +63 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/policy.d.ts +15 -0
- package/dist/policy.js +93 -0
- package/dist/settings.d.ts +27 -0
- package/dist/settings.js +32 -0
- package/dist/shell.d.ts +4 -0
- package/dist/shell.js +75 -0
- package/files/commit-msg +3 -0
- package/files/dependabot.yaml +40 -0
- package/files/editorconfig +12 -0
- package/files/gitattributes +2 -0
- package/files/gitignore +46 -0
- package/files/gitleaks.toml +9 -0
- package/files/mise.toml +4 -0
- package/package.json +44 -3
- package/rules.md +41 -0
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
|
-
#
|
|
1
|
+
# @ryusenkei/conventions
|
|
2
2
|
|
|
3
|
-
|
|
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
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
|
+
}
|
package/dist/files.d.ts
ADDED
|
@@ -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
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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";
|
package/dist/policy.d.ts
ADDED
|
@@ -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 {};
|
package/dist/settings.js
ADDED
|
@@ -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
|
+
}
|
package/dist/shell.d.ts
ADDED
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
|
+
}
|
package/files/commit-msg
ADDED
|
@@ -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
|
+
- "*"
|
package/files/gitignore
ADDED
|
@@ -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/
|
package/files/mise.toml
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryusenkei/conventions",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
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": false
|
|
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 の構成・コマンドの記述が変更と一致
|