@shinaps/skills 0.48.0 → 0.50.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/README.md +23 -7
- package/dist/bin/skills.js +6 -0
- package/dist/lib/args.js +11 -0
- package/dist/lib/fs.js +21 -2
- package/dist/lib/install.js +8 -4
- package/dist/lib/lint.js +9 -6
- package/dist/lib/managed-session.js +2 -3
- package/dist/lib/rule-coverage.js +151 -0
- package/dist/lib/rule-snapshot.js +261 -0
- package/dist/lib/transaction.js +2 -5
- package/docs/agents.md +2 -5
- package/docs/development.md +6 -3
- package/docs/install.md +13 -2
- package/docs/lint.md +2 -0
- package/package.json +1 -1
- package/plugins/shinaps-claude/.claude-plugin/plugin.json +1 -1
- package/plugins/shinaps-claude/agents/shinaps-reviewer.md +22 -10
- package/plugins/shinaps-claude/agents/shinaps-survey-validator.md +1 -1
- package/plugins/shinaps-claude/agents/shinaps-tech-surveyor.md +1 -1
- package/plugins/shinaps-claude/hooks/capture-rule-response.mjs +27 -0
- package/plugins/shinaps-claude/hooks/hooks.json +15 -0
- package/plugins/shinaps-claude/managed-agents/agents/shinaps-reviewer.md +22 -10
- package/plugins/shinaps-claude/managed-agents/skills/dev-managed-cc/references/review.md +70 -0
- package/plugins/shinaps-claude/managed-agents/skills/dev-managed-cc/references/staffing.md +2 -6
- package/plugins/shinaps-claude/managed-agents/skills/dev-managed-cc/references/workflow.md +3 -17
- package/plugins/shinaps-claude/references/dev/launch.md +1 -1
- package/plugins/shinaps-claude/references/dev/staffing.md +3 -7
- package/plugins/shinaps-claude/references/dev/workflow.md +15 -31
- package/plugins/shinaps-claude/references/linear/claude-cli.md +2 -0
- package/plugins/shinaps-claude/references/linear/configuration.md +1 -1
- package/plugins/shinaps-claude/references/lint.md +6 -5
- package/plugins/shinaps-claude/references/request-scope.md +1 -1
- package/plugins/shinaps-claude/references/review-standalone.md +58 -0
- package/plugins/shinaps-claude/references/review.md +70 -0
- package/plugins/shinaps-claude/references/rule-writing.md +9 -0
- package/plugins/shinaps-claude/references/rules.md +57 -5
- package/plugins/shinaps-claude/skills/review/SKILL.md +10 -134
- package/plugins/shinaps-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/shinaps-codex/agents/shinaps-reviewer.toml +2 -2
- package/plugins/shinaps-codex/agents/shinaps-survey-validator.toml +1 -1
- package/plugins/shinaps-codex/agents/shinaps-tech-surveyor.toml +1 -1
- package/plugins/shinaps-codex/hooks/capture-rule-response.mjs +27 -0
- package/plugins/shinaps-codex/hooks/hooks.json +15 -0
- package/plugins/shinaps-codex/managed-agents/agents/shinaps-reviewer.md +22 -10
- package/plugins/shinaps-codex/managed-agents/skills/dev-managed-cc/references/review.md +70 -0
- package/plugins/shinaps-codex/managed-agents/skills/dev-managed-cc/references/staffing.md +2 -6
- package/plugins/shinaps-codex/managed-agents/skills/dev-managed-cc/references/workflow.md +3 -17
- package/plugins/shinaps-codex/references/dev/launch.md +1 -1
- package/plugins/shinaps-codex/references/dev/staffing.md +3 -7
- package/plugins/shinaps-codex/references/dev/workflow.md +15 -31
- package/plugins/shinaps-codex/references/linear/claude-cli.md +2 -0
- package/plugins/shinaps-codex/references/linear/configuration.md +1 -1
- package/plugins/shinaps-codex/references/lint.md +6 -5
- package/plugins/shinaps-codex/references/request-scope.md +1 -1
- package/plugins/shinaps-codex/references/review-standalone.md +58 -0
- package/plugins/shinaps-codex/references/review.md +70 -0
- package/plugins/shinaps-codex/references/rule-writing.md +9 -0
- package/plugins/shinaps-codex/references/rules.md +57 -5
- package/plugins/shinaps-codex/skills/review/SKILL.md +10 -9
- package/plugins/shinaps-claude/agents/shinaps-refine-agent.md +0 -80
- package/plugins/shinaps-claude/agents/shinaps-review-validator.md +0 -49
- package/plugins/shinaps-claude/agents/shinaps-simplifier.md +0 -36
- package/plugins/shinaps-claude/skills/extract-rules/SKILL.md +0 -118
- package/plugins/shinaps-claude/skills/refine/SKILL.md +0 -209
- package/plugins/shinaps-claude/skills/simplify/SKILL.md +0 -37
- package/plugins/shinaps-codex/agents/shinaps-refine-agent.toml +0 -4
- package/plugins/shinaps-codex/agents/shinaps-review-validator.toml +0 -4
- package/plugins/shinaps-codex/skills/extract-rules/SKILL.md +0 -16
- package/plugins/shinaps-codex/skills/refine/SKILL.md +0 -16
- package/plugins/shinaps-codex/skills/simplify/SKILL.md +0 -26
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Claude Code と Codex で使う、開発ワークフローのスキル集です。調査・仕様の整理・実装・レビュー・コミット・PR・リリースの手順を、それぞれのクライアント向けの plugin として配布します。
|
|
4
4
|
|
|
5
5
|
- 実装は Claude Code が行います。Codex は調査・レビュー・Linear での進行管理と QA を担当します。
|
|
6
|
-
- 導入に Shinaps AI のアカウントは不要です。コーディング規約(rule
|
|
6
|
+
- 導入に Shinaps AI のアカウントは不要です。コーディング規約(rule)の照合・登録を使うときだけ、[Shinaps AI](https://app.shinaps.ai) でアカウントを作成して MCP を接続します。
|
|
7
7
|
|
|
8
8
|
## 必要なもの
|
|
9
9
|
|
|
@@ -36,7 +36,7 @@ Claude Code では `/shinaps:<スキル名>`(例 `/shinaps:review`)で呼び
|
|
|
36
36
|
|
|
37
37
|
1. `explore` で今の挙動と変更の影響を調べる。要件が曖昧なら `spec` で仕様を詰める。
|
|
38
38
|
2. Claude Code の `dev-claude` で実装する。体制(LITE・BASIC・ULTRA)を選ぶと、実装とは別の担当が計画や実装をレビューします。
|
|
39
|
-
3. `
|
|
39
|
+
3. `review` で差分を確かめる。体制(LITE・BASIC・ULTRA)を選べ、規約照合や簡素化だけの依頼もできます。追加修正の後や、実装時と違う体制で確かめたいときにも単独で使えます。指摘の検証と報告までを行い、修正は依頼されたときだけ開発の担当へ引き継ぎます。
|
|
40
40
|
4. `commit`・`create-pr`・`release` で、コミット・PR・公開を行う。これらは依頼されたとき、または Linear で合意した終了地点までの進行を依頼されたときに実行します。操作ごとの許可の一覧は持たず、依頼の範囲とプロジェクトの制約で判断します([依頼範囲と操作](plugins/shinaps-claude/references/request-scope.md))。
|
|
41
41
|
|
|
42
42
|
スキルは、対象プロジェクトの `AGENTS.md`・`CLAUDE.md`・README・CI・既存のスクリプトから、対象の環境・必要な検証・公開の条件を読み取ります。書かれていない条件は、結果を左右するものだけを確認します。Linear と Claude Managed Agents 以外のスキルは、専用の設定ファイル無しで使えます。`linear-execute` での開発の進行には `.shinaps/linear.json`、Managed Agents への依頼には `.shinaps/managed.json` が必要です(下の「機能別の資料」)。
|
|
@@ -49,10 +49,7 @@ Claude Code では `/shinaps:<スキル名>`(例 `/shinaps:review`)で呼び
|
|
|
49
49
|
| `spec` | 目的・仕様・実現方法を詰める |
|
|
50
50
|
| `dev-claude`(Claude のみ) | 選んだ体制で実装し、別の担当が独立にレビューする |
|
|
51
51
|
| `debug` | 不具合の原因を調べて確かめる |
|
|
52
|
-
| `review` |
|
|
53
|
-
| `refine` | Shinaps AI の rule と実際のファイルを照合する |
|
|
54
|
-
| `extract-rules` | PR のレビューから rule を抽出する |
|
|
55
|
-
| `simplify` | 目的から見直して、不要な構造や手順を削る |
|
|
52
|
+
| `review` | 差分や既存のコードを、選んだ体制でレビューする(規約照合・可読性・慣習・簡素化を含む) |
|
|
56
53
|
| `commit` | 変更を単位に分けてコミットする |
|
|
57
54
|
| `create-pr` | リポジトリの規約に合わせて PR を作る |
|
|
58
55
|
| `release` | 公開条件を確かめて公開し、対象の版を使えることを確かめる |
|
|
@@ -67,6 +64,7 @@ Claude Code では `/shinaps:<スキル名>`(例 `/shinaps:review`)で呼び
|
|
|
67
64
|
|---|---|
|
|
68
65
|
| 更新・削除・macOS での Codex の自動更新 | [導入と更新](docs/install.md) |
|
|
69
66
|
| `dev-claude` の体制と工程 | [実装体制の判断](plugins/shinaps-claude/references/dev/staffing.md)・[dev の工程](plugins/shinaps-claude/references/dev/workflow.md) |
|
|
67
|
+
| レビューの担当の構成と指摘の採否・単独起動の手順 | [レビューの共通契約](plugins/shinaps-claude/references/review.md)・[単独レビューの手順](plugins/shinaps-claude/references/review-standalone.md) |
|
|
70
68
|
| Linear での開発進行(`.shinaps/linear.json` の書き方・終了地点と制約・auto と manual) | [プロジェクト設定](plugins/shinaps-claude/references/linear/configuration.md)・[担当と工程](plugins/shinaps-claude/references/linear/workflow.md) |
|
|
71
69
|
| Claude Managed Agents(必要なもの・導入・依頼) | [設定とアカウント](plugins/shinaps-claude/references/managed/configuration.md)・[導入と更新](plugins/shinaps-claude/references/managed/setup.md)・[依頼と観測](plugins/shinaps-claude/references/managed/execute.md) |
|
|
72
70
|
| スキルが使う担当(サブエージェント)の一覧 | [サブエージェント](docs/agents.md) |
|
|
@@ -75,9 +73,27 @@ Claude Code では `/shinaps:<スキル名>`(例 `/shinaps:review`)で呼び
|
|
|
75
73
|
|
|
76
74
|
旧版の設定は自動では変換しません。Linear は [旧設定の移行](plugins/shinaps-claude/references/linear/configuration.md#旧設定の移行)(0.48.0 から `config_version: 3`。操作ごとの許可の一覧を廃止し `constraints` に置き換え)、Managed Agents は [version 1 からの移行](plugins/shinaps-claude/references/managed/configuration.md#version-1-からの移行)に従ってください。
|
|
77
75
|
|
|
76
|
+
### 0.50.0 の変更
|
|
77
|
+
|
|
78
|
+
- 規約の照合は、plugin の hook が保存した応答から作る snapshot を担当と機械検査が読みます。Codex では hook の信頼が要り、plugin の更新のたびに `/hooks` で信頼し直します([導入と更新](docs/install.md#規約の-hook))。
|
|
79
|
+
- 確定した規約の適用結果を既定で本人の集計へ記録します。作業ごとに OFF にでき、直近 90 日で集計から外れて削除されます([導入と更新](docs/install.md#規約の適用結果の記録))。Shinaps AI 側の配備が先に要ります。
|
|
80
|
+
|
|
81
|
+
### 0.49.0 の変更
|
|
82
|
+
|
|
83
|
+
`refine`・`simplify`・`extract-rules` のスキルを廃止しました。規約照合と簡素化は `review` に依頼します(「規約照合だけ」「簡素化だけ」と限定できます)。規約の登録はその場で AI に依頼します。PR からの一括抽出の代わりになるスキルはありません。計画の簡素化だけを単独で起動する手段も無くなり、dev の計画レビューに含まれます。
|
|
84
|
+
|
|
85
|
+
`review` の名前は同じですが、次の動作が変わります。
|
|
86
|
+
|
|
87
|
+
- 既定では指摘の検証と報告で終わり、修正や次の操作の質問をしません。修正まで依頼すると、開発の担当が修正と再レビューを行い、未コミットの差分を返します。Codex の `review` は Linear 進行でなくてもローカルの Claude CLI を権限確認を省くモード(`bypassPermissions`)で起動して修正を渡します。操作は依頼の範囲に限り、CLI が無いときは止まります。
|
|
88
|
+
- 指摘の独立検証と外部調査は、毎回ではなく、依頼されたときや判断を左右するときに行います。
|
|
89
|
+
- 変更を対象にしたレビューでは、変更と無関係な既存箇所の規約違反を Critical にせず、別の一覧で報告します。ファイル・ディレクトリを指定した場合は、指定した全体を対象に重要度を下げません。
|
|
90
|
+
- 引数なしの対象は、ベースブランチからのコミット済みの差分と作業ツリーの変更です。
|
|
91
|
+
|
|
92
|
+
dev の実装後レビューでは、規約に機械検査があるときに配布 CLI の `lint` を使います(使えないときは AI が照合します)。
|
|
93
|
+
|
|
78
94
|
## Shinaps AI との接続
|
|
79
95
|
|
|
80
|
-
`
|
|
96
|
+
`review` や `dev-claude` の規約照合など rule を使う工程に必要です。確定した規約の適用結果は既定で本人の集計へ記録します(送るのは規約の id、作業の key、結果の別だけで、作業ごとに OFF にでき、90 日で削除されます。[導入と更新](docs/install.md#規約の適用結果の記録))。規約の全文は plugin の hook が応答をそのまま保存した snapshot から担当へ渡します。Codex では hook の信頼が要り、plugin の更新のたびに信頼し直します([導入と更新](docs/install.md#規約の-hook))。規約の登録・更新はその場で AI に依頼します([規約の書き方](plugins/shinaps-claude/references/rule-writing.md))。導入とは別に設定し、既存の登録があれば再利用します。同じサーバーを plugin とグローバル設定の両方に登録しないでください。認証情報は各クライアントの認証で管理し、このリポジトリの CLI では扱いません。
|
|
81
97
|
|
|
82
98
|
```sh
|
|
83
99
|
# Claude Code(登録後、Claude Code 内の /mcp から認証)
|
package/dist/bin/skills.js
CHANGED
|
@@ -8,6 +8,8 @@ import { managed } from "../lib/managed.js";
|
|
|
8
8
|
import { run } from "../lib/install.js";
|
|
9
9
|
import { autoUpdate } from "../lib/auto-update.js";
|
|
10
10
|
import { lint } from "../lib/lint.js";
|
|
11
|
+
import { ruleCoverage } from "../lib/rule-coverage.js";
|
|
12
|
+
import { ruleSnapshot } from "../lib/rule-snapshot.js";
|
|
11
13
|
import { errorMessage } from "../lib/fs.js";
|
|
12
14
|
const [command, ...rest] = process.argv.slice(2);
|
|
13
15
|
try {
|
|
@@ -32,6 +34,10 @@ try {
|
|
|
32
34
|
await autoUpdate(rest);
|
|
33
35
|
else if (command === 'lint')
|
|
34
36
|
await lint(rest);
|
|
37
|
+
else if (command === 'rule-snapshot')
|
|
38
|
+
await ruleSnapshot(rest);
|
|
39
|
+
else if (command === 'rule-coverage')
|
|
40
|
+
await ruleCoverage(rest);
|
|
35
41
|
else
|
|
36
42
|
await run(process.argv.slice(2));
|
|
37
43
|
}
|
package/dist/lib/args.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** `--` の前の引数と、後ろの対象ファイルに分ける。 */
|
|
2
|
+
export function splitFiles(args) {
|
|
3
|
+
const separator = args.indexOf('--');
|
|
4
|
+
return separator === -1
|
|
5
|
+
? { flags: args, files: [] }
|
|
6
|
+
: { flags: args.slice(0, separator), files: args.slice(separator + 1) };
|
|
7
|
+
}
|
|
8
|
+
/** `--name value` を、繰り返しも含めて読む。 */
|
|
9
|
+
export function flagValues(args, name) {
|
|
10
|
+
return args.flatMap((arg, index) => (arg === name && args[index + 1] ? [args[index + 1]] : []));
|
|
11
|
+
}
|
package/dist/lib/fs.js
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { basename, dirname, join } from 'node:path';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
4
|
/** OS エラーの code (ENOENT など)。Error 以外や code を持たないエラーは undefined。 */
|
|
5
5
|
export const errorCode = (error) => error instanceof Error ? error.code : undefined;
|
|
6
6
|
export const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
7
|
+
export const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
|
8
|
+
/**
|
|
9
|
+
* ファイルの内容の sha256。
|
|
10
|
+
* @param root files の基準のディレクトリ
|
|
11
|
+
* @param files root からの相対パス。無いファイルは結果に含めない
|
|
12
|
+
*/
|
|
13
|
+
export async function hashFiles(root, files) {
|
|
14
|
+
const hashes = {};
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
try {
|
|
17
|
+
hashes[file] = sha256(await readFile(resolve(root, file)));
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (errorCode(error) !== 'ENOENT')
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return hashes;
|
|
25
|
+
}
|
|
7
26
|
export const readJson = async (path) => JSON.parse(await readFile(path, 'utf8'));
|
|
8
27
|
/** JSON ファイルを読む。無ければ null。 */
|
|
9
28
|
export async function readJsonIfExists(path) {
|
package/dist/lib/install.js
CHANGED
|
@@ -2,9 +2,11 @@ import { readFile, readdir, cp, rm, mkdir } from 'node:fs/promises';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
-
import {
|
|
5
|
+
import { registerPlugin, replaceInstallation, restore, } from "./transaction.js";
|
|
6
6
|
import { withLock } from "./lock.js";
|
|
7
|
-
import { readTextIfExists, writeFileAtomic } from "./fs.js";
|
|
7
|
+
import { readTextIfExists, sha256, writeFileAtomic } from "./fs.js";
|
|
8
|
+
import { RULE_COVERAGE_COMMAND } from "./rule-coverage.js";
|
|
9
|
+
import { RULE_SNAPSHOT_COMMAND } from "./rule-snapshot.js";
|
|
8
10
|
import { codexHomeMismatch, codexHomeOf, isRecordedCodexHome, managedAgent, managedRoot, marketplaceName, packageRoot, payloadDir, payloadPaths, pluginDir, pluginId, statePath, } from "./layout.js";
|
|
9
11
|
const actions = ['install', 'update', 'uninstall', 'recover'];
|
|
10
12
|
const isAction = (value) => actions.includes(value);
|
|
@@ -15,6 +17,8 @@ const usage = [
|
|
|
15
17
|
' shinaps-skills managed <setup|run|status|reply|collect> (see managed --help)',
|
|
16
18
|
' shinaps-skills auto-update <enable|disable|status|check> codex',
|
|
17
19
|
' shinaps-skills lint --checks <checks.json> --root <project> --out <result.json> -- <files...>',
|
|
20
|
+
` ${RULE_SNAPSHOT_COMMAND}`,
|
|
21
|
+
` ${RULE_COVERAGE_COMMAND}`,
|
|
18
22
|
'Claude Code: /plugin marketplace add sh1n4ps/shinaps-skills',
|
|
19
23
|
' /plugin install shinaps@shinaps-skills',
|
|
20
24
|
].join('\n');
|
|
@@ -173,7 +177,7 @@ async function readVerifiedState(installation) {
|
|
|
173
177
|
const current = await readTextIfExists(join(agentsDir, name));
|
|
174
178
|
if (current === null)
|
|
175
179
|
continue;
|
|
176
|
-
const currentHash =
|
|
180
|
+
const currentHash = sha256(current);
|
|
177
181
|
if (currentHash !== hash && currentHash !== state.pending?.[name]) {
|
|
178
182
|
throw new Error(`Modified agent preserved: ${join(agentsDir, name)}. Move the edited file elsewhere or restore its content, then retry.`);
|
|
179
183
|
}
|
|
@@ -192,7 +196,7 @@ async function readPackagedAgents(installation, state) {
|
|
|
192
196
|
if (!(name in state.agents) && (await readTextIfExists(join(agentsDir, name))) !== null) {
|
|
193
197
|
throw new Error(`Unmanaged agent preserved: ${join(agentsDir, name)}. Shinaps did not install this file. Move or rename it, then retry.`);
|
|
194
198
|
}
|
|
195
|
-
agents[name] = { content, hash:
|
|
199
|
+
agents[name] = { content, hash: sha256(content) };
|
|
196
200
|
}
|
|
197
201
|
return agents;
|
|
198
202
|
}
|
package/dist/lib/lint.js
CHANGED
|
@@ -2,7 +2,8 @@ import { readFile, writeFile } from 'node:fs/promises';
|
|
|
2
2
|
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
3
|
import { checkKey, classify, parseLintInput, planUnits, } from "./lint-plan.js";
|
|
4
4
|
import { shinapsPlugin } from "./lint-rules.js";
|
|
5
|
-
import {
|
|
5
|
+
import { splitFiles } from "./args.js";
|
|
6
|
+
import { errorMessage, hashFiles, sha256 } from "./fs.js";
|
|
6
7
|
const USAGE = 'Usage: shinaps-skills lint --checks <checks.json> --root <project> --out <result.json> -- <files...>';
|
|
7
8
|
/**
|
|
8
9
|
* `shinaps-skills lint`: 規約の検査を、プロジェクトの設定を使わずに ESLint の Node API で実行し、
|
|
@@ -12,9 +13,13 @@ const USAGE = 'Usage: shinaps-skills lint --checks <checks.json> --root <project
|
|
|
12
13
|
*/
|
|
13
14
|
export async function lint(args) {
|
|
14
15
|
const options = parseArgs(args);
|
|
15
|
-
const
|
|
16
|
+
const checksText = await readFile(options.checks);
|
|
17
|
+
const input = parseLintInput(JSON.parse(checksText.toString('utf8')));
|
|
18
|
+
const files = await hashFiles(options.root, options.files.map((file) => toRelative(options.root, file)));
|
|
16
19
|
const result = await runLint(input, options.root, options.files);
|
|
17
|
-
|
|
20
|
+
// 結果がどの検査とどの内容のファイルから作られたかを残し、古い結果を機械確認として使わないようにする。
|
|
21
|
+
const output = { ...result, checksSha256: sha256(checksText), files };
|
|
22
|
+
await writeFile(options.out, `${JSON.stringify(output, null, 2)}\n`);
|
|
18
23
|
const counts = Object.fromEntries(['covered', 'violation', 'partial', 'not-run'].map((state) => [
|
|
19
24
|
state,
|
|
20
25
|
result.pairs.filter((pair) => pair.state === state).length,
|
|
@@ -22,9 +27,7 @@ export async function lint(args) {
|
|
|
22
27
|
console.log(`Shinaps lint: ${JSON.stringify(counts)} → ${options.out}`);
|
|
23
28
|
}
|
|
24
29
|
function parseArgs(args) {
|
|
25
|
-
const
|
|
26
|
-
const flags = separator === -1 ? args : args.slice(0, separator);
|
|
27
|
-
const files = separator === -1 ? [] : args.slice(separator + 1);
|
|
30
|
+
const { flags, files } = splitFiles(args);
|
|
28
31
|
const value = (name) => {
|
|
29
32
|
const index = flags.indexOf(name);
|
|
30
33
|
const found = index === -1 ? undefined : flags[index + 1];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
|
-
import { readJson, readJsonIfExists, writeFileAtomic } from "./fs.js";
|
|
4
|
+
import { readJson, readJsonIfExists, sha256, writeFileAtomic } from "./fs.js";
|
|
5
5
|
import { withLock } from "./lock.js";
|
|
6
6
|
import { filesBeta, managedBeta } from "./managed-ant.js";
|
|
7
7
|
import { importSnapshot } from "./managed-snapshot.js";
|
|
@@ -14,7 +14,6 @@ const permissionLabels = {
|
|
|
14
14
|
push: 'headへのpush',
|
|
15
15
|
draft_pr: 'draft PRの作成',
|
|
16
16
|
};
|
|
17
|
-
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
|
18
17
|
/**
|
|
19
18
|
* `--transform id -r` の出力から ID を取り出す。ant は transform のパスが応答に無いと応答の全体を
|
|
20
19
|
* 出すので、ID の形でない出力を ID として記録しない。
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, normalize, relative } from 'node:path';
|
|
3
|
+
import { flagValues, splitFiles } from "./args.js";
|
|
4
|
+
import { hashFiles, readJson } from "./fs.js";
|
|
5
|
+
import { verifySnapshot } from "./rule-snapshot.js";
|
|
6
|
+
export const RULE_COVERAGE_COMMAND = 'shinaps-skills rule-coverage --manifest <manifest.json> --target-version <version> [--lint <result.json>] [--report <report.json> ...] --out <coverage.json> -- <files...>';
|
|
7
|
+
const NO_REPORT = 'no report for this snapshot and target version covers this file';
|
|
8
|
+
/**
|
|
9
|
+
* 規約×ファイルの組を次の優先順でちょうど一つに区分する。機械が保証するのは同じ snapshot と ID・保存データ・対象の版の
|
|
10
|
+
* 整合までで、reported は担当の申告(読了や理解の証明ではない)。
|
|
11
|
+
* - machine: lint の covered・violation
|
|
12
|
+
* - reported: この snapshot と版の報告のどれかが、そのファイルを completedFiles に挙げ、その規約を unchecked に挙げていない
|
|
13
|
+
* - unconfirmed: それ以外。報告に書かれていないファイルは、省略から自動で reported にならない
|
|
14
|
+
* @param input.manifest snapshot の manifest
|
|
15
|
+
* @param input.targetVersion 確認対象の固定版。報告の版と比べる
|
|
16
|
+
* @param input.files 対象ファイル(lint の結果と同じ基準の相対パス)
|
|
17
|
+
* @param input.lint 最新の版で実行した lint の結果。実行できなければ null
|
|
18
|
+
* @param input.reports 担当の報告。分担したときは担当ごとの要素を全部渡す。対象の外のファイルの記述は数えない
|
|
19
|
+
*/
|
|
20
|
+
export function classifyCoverage(input) {
|
|
21
|
+
const { manifest, targetVersion } = input;
|
|
22
|
+
const targets = new Set(input.files.map((file) => normalize(file)));
|
|
23
|
+
const ids = new Set(manifest.ruleIds);
|
|
24
|
+
const machinePairKeys = new Set((input.lint?.pairs ?? [])
|
|
25
|
+
.filter((pair) => pair.state === 'covered' || pair.state === 'violation')
|
|
26
|
+
.map((pair) => `${pair.ruleId}\0${normalize(pair.file)}`));
|
|
27
|
+
const reports = input.reports
|
|
28
|
+
.filter((entry) => entry.contentHash === manifest.contentHash && entry.targetVersion === targetVersion)
|
|
29
|
+
.map((entry) => {
|
|
30
|
+
const incomplete = new Map(entry.incompleteFiles.map((file) => [normalize(file.path), file.reason]));
|
|
31
|
+
return {
|
|
32
|
+
incomplete,
|
|
33
|
+
completed: new Set(entry.completedFiles
|
|
34
|
+
.map((file) => normalize(file))
|
|
35
|
+
.filter((file) => !incomplete.has(file))),
|
|
36
|
+
unchecked: new Map(entry.unchecked.map((rule) => [rule.id, rule.reason])),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
for (const { unchecked } of reports) {
|
|
40
|
+
for (const id of unchecked.keys()) {
|
|
41
|
+
if (!ids.has(id))
|
|
42
|
+
throw new Error(`The report names a rule outside the snapshot: ${id}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const counts = { machine: 0, reported: 0, unconfirmed: 0 };
|
|
46
|
+
const unconfirmedRules = new Map();
|
|
47
|
+
const unconfirmedFiles = new Map();
|
|
48
|
+
for (const id of manifest.ruleIds) {
|
|
49
|
+
for (const file of targets) {
|
|
50
|
+
const covering = reports.filter((report) => report.completed.has(file));
|
|
51
|
+
if (machinePairKeys.has(`${id}\0${file}`)) {
|
|
52
|
+
counts.machine++;
|
|
53
|
+
}
|
|
54
|
+
else if (covering.some((report) => !report.unchecked.has(id))) {
|
|
55
|
+
counts.reported++;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
counts.unconfirmed++;
|
|
59
|
+
const ruleReason = reports.find((report) => report.unchecked.has(id))?.unchecked.get(id);
|
|
60
|
+
if (ruleReason !== undefined)
|
|
61
|
+
unconfirmedRules.set(id, ruleReason);
|
|
62
|
+
if (covering.length === 0) {
|
|
63
|
+
const fileReason = reports
|
|
64
|
+
.find((report) => report.incomplete.has(file))
|
|
65
|
+
?.incomplete.get(file);
|
|
66
|
+
unconfirmedFiles.set(file, fileReason ?? NO_REPORT);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
contentHash: manifest.contentHash,
|
|
73
|
+
targetVersion,
|
|
74
|
+
counts,
|
|
75
|
+
unconfirmedRules: [...unconfirmedRules].map(([id, reason]) => ({ id, reason })),
|
|
76
|
+
unconfirmedFiles: [...unconfirmedFiles].map(([path, reason]) => ({ path, reason })),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const isKeyWithReason = (entry, key) => typeof entry?.[key] === 'string' && typeof entry.reason === 'string';
|
|
80
|
+
const isReport = (entry) => typeof entry?.contentHash === 'string' &&
|
|
81
|
+
typeof entry.targetVersion === 'string' &&
|
|
82
|
+
Array.isArray(entry.completedFiles) &&
|
|
83
|
+
entry.completedFiles.every((file) => typeof file === 'string') &&
|
|
84
|
+
Array.isArray(entry.unchecked) &&
|
|
85
|
+
entry.unchecked.every((item) => isKeyWithReason(item, 'id')) &&
|
|
86
|
+
Array.isArray(entry.incompleteFiles) &&
|
|
87
|
+
entry.incompleteFiles.every((item) => isKeyWithReason(item, 'path'));
|
|
88
|
+
/** AI が書いた報告を読む。形の揺れを受け入れると未確認が一覧から消えるので、何を直すかが分かるように失敗する。 */
|
|
89
|
+
function parseReports(raw) {
|
|
90
|
+
if (!Array.isArray(raw) || !raw.every(isReport)) {
|
|
91
|
+
throw new Error('The report must be an array of { contentHash, targetVersion, completedFiles: [], unchecked: [{ id, reason }], incompleteFiles: [{ path, reason }] }.');
|
|
92
|
+
}
|
|
93
|
+
return raw;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* lint の結果を、この snapshot と現在の内容のものだけ受け入れる。機械確認の組は担当の照合から外すので、古い結果を使うと
|
|
97
|
+
* 誰も見ていない組が残る。
|
|
98
|
+
*/
|
|
99
|
+
async function readCurrentLint(path, manifest) {
|
|
100
|
+
const lint = await readJson(path);
|
|
101
|
+
if (lint.checksSha256 !== manifest.lintChecksSha256) {
|
|
102
|
+
throw new Error('The lint result was not made from this snapshot. Run lint again.');
|
|
103
|
+
}
|
|
104
|
+
const current = await hashFiles(lint.root, Object.keys(lint.files));
|
|
105
|
+
for (const [file, hash] of Object.entries(lint.files)) {
|
|
106
|
+
if (current[file] !== hash)
|
|
107
|
+
throw new Error(`${file} changed after lint. Run lint again.`);
|
|
108
|
+
}
|
|
109
|
+
return lint;
|
|
110
|
+
}
|
|
111
|
+
export async function ruleCoverage(args) {
|
|
112
|
+
const { flags, files } = splitFiles(args);
|
|
113
|
+
const value = (name) => flagValues(flags, name)[0];
|
|
114
|
+
const manifestPath = value('--manifest');
|
|
115
|
+
const targetVersion = value('--target-version');
|
|
116
|
+
const out = value('--out');
|
|
117
|
+
if (manifestPath === undefined ||
|
|
118
|
+
targetVersion === undefined ||
|
|
119
|
+
out === undefined ||
|
|
120
|
+
files.length === 0) {
|
|
121
|
+
throw new Error(`Usage: ${RULE_COVERAGE_COMMAND}`);
|
|
122
|
+
}
|
|
123
|
+
const manifest = await verifySnapshot(manifestPath);
|
|
124
|
+
const lintPath = value('--lint');
|
|
125
|
+
const lint = lintPath === undefined ? null : await readCurrentLint(lintPath, manifest);
|
|
126
|
+
const toTarget = (file) => normalize(isAbsolute(file) ? relative(lint?.root ?? process.cwd(), file) : file);
|
|
127
|
+
const targets = files.map(toTarget);
|
|
128
|
+
// lint の対象に無いファイルは機械確認にならない。パスの基準がずれると、外した組を誰も見ないまま申告に数えてしまう。
|
|
129
|
+
const linted = new Set(Object.keys(lint?.files ?? {}).map((file) => normalize(file)));
|
|
130
|
+
const outside = lint === null ? undefined : targets.find((file) => !linted.has(file));
|
|
131
|
+
if (outside !== undefined) {
|
|
132
|
+
throw new Error(`${outside} is not in the lint result. Run lint again with the same files.`);
|
|
133
|
+
}
|
|
134
|
+
const reports = await Promise.all(flagValues(flags, '--report').map(async (path) => parseReports(await readJson(path))));
|
|
135
|
+
const coverage = classifyCoverage({
|
|
136
|
+
manifest,
|
|
137
|
+
targetVersion,
|
|
138
|
+
files: targets,
|
|
139
|
+
lint,
|
|
140
|
+
reports: reports.flat().map((report) => ({
|
|
141
|
+
...report,
|
|
142
|
+
completedFiles: report.completedFiles.map(toTarget),
|
|
143
|
+
incompleteFiles: report.incompleteFiles.map((file) => ({
|
|
144
|
+
...file,
|
|
145
|
+
path: toTarget(file.path),
|
|
146
|
+
})),
|
|
147
|
+
})),
|
|
148
|
+
});
|
|
149
|
+
await writeFile(out, `${JSON.stringify(coverage, null, 1)}\n`);
|
|
150
|
+
console.log(`Shinaps rule coverage: ${JSON.stringify(coverage.counts)} → ${out}`);
|
|
151
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
import { flagValues } from "./args.js";
|
|
6
|
+
import { errorMessage, readJson, sha256 } from "./fs.js";
|
|
7
|
+
export const RULE_SNAPSHOT_COMMAND = 'shinaps-skills rule-snapshot --since <epoch-ms> --input <search-input.json> [--input ...] [--exclude-label <slug> ...] [--captures <dir>] --out <dir>';
|
|
8
|
+
// Codex の担当のファイル読取りが切り詰められない大きさ(モックのモデルで 8KB は全文が届き、18KB は切り詰められた)。
|
|
9
|
+
// 1 件で超える規約は 1 件だけの part にし、行範囲で読めるよう整形する。
|
|
10
|
+
const PART_MAX_BYTES = 8000;
|
|
11
|
+
const CAPTURE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
13
|
+
function canonical(value) {
|
|
14
|
+
if (Array.isArray(value))
|
|
15
|
+
return value.map(canonical);
|
|
16
|
+
if (!isRecord(value))
|
|
17
|
+
return value;
|
|
18
|
+
return Object.fromEntries(Object.entries(value)
|
|
19
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
20
|
+
.map(([key, item]) => [key, canonical(item)]));
|
|
21
|
+
}
|
|
22
|
+
const inputKey = ({ cursor: _cursor, ...rest }) => JSON.stringify(canonical(rest));
|
|
23
|
+
const isRule = (value) => isRecord(value) && typeof value.id === 'string';
|
|
24
|
+
const isPage = (value) => isRecord(value) &&
|
|
25
|
+
Array.isArray(value.userRules) &&
|
|
26
|
+
value.userRules.every(isRule) &&
|
|
27
|
+
Array.isArray(value.officialRules) &&
|
|
28
|
+
value.officialRules.every(isRule) &&
|
|
29
|
+
'nextCursor' in value;
|
|
30
|
+
/** 応答の本文からページを取り出す。切り詰めやエラーの応答はページの形にならないので null になる。 */
|
|
31
|
+
function parsePage(response) {
|
|
32
|
+
const content = isRecord(response) ? response.content : response;
|
|
33
|
+
if (!Array.isArray(content))
|
|
34
|
+
return null;
|
|
35
|
+
const text = content.map((item) => (isRecord(item) ? item.text : '')).join('');
|
|
36
|
+
let page;
|
|
37
|
+
try {
|
|
38
|
+
page = JSON.parse(text);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return isPage(page) ? page : null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 捕捉の形式: plugin の hook が PostToolUse の標準入力の全体を
|
|
47
|
+
* `<captures>/<session>/<時刻 ms>-<tool_use_id>.json` に書いたもの。使う項目は tool_input と tool_response で、
|
|
48
|
+
* tool_response は Claude Code では content の配列、Codex では `{ content }`。
|
|
49
|
+
* @param session 捕捉の session のディレクトリ名
|
|
50
|
+
* @param name 捕捉のファイル名(先頭が時刻 ms)
|
|
51
|
+
* @param text 捕捉のファイルの内容
|
|
52
|
+
*/
|
|
53
|
+
function parseCapture(session, name, text) {
|
|
54
|
+
const event = JSON.parse(text);
|
|
55
|
+
const time = Number.parseInt(name, 10);
|
|
56
|
+
if (!isRecord(event) || !isRecord(event.tool_input) || Number.isNaN(time))
|
|
57
|
+
return null;
|
|
58
|
+
return { session, time, name, input: event.tool_input, page: parsePage(event.tool_response) };
|
|
59
|
+
}
|
|
60
|
+
export async function readCaptures(root) {
|
|
61
|
+
if (!existsSync(root))
|
|
62
|
+
return [];
|
|
63
|
+
const captures = [];
|
|
64
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
65
|
+
if (!entry.isDirectory())
|
|
66
|
+
continue;
|
|
67
|
+
const session = entry.name;
|
|
68
|
+
for (const name of await readdir(join(root, session))) {
|
|
69
|
+
if (!name.endsWith('.json'))
|
|
70
|
+
continue;
|
|
71
|
+
const capture = parseCapture(session, name, await readFile(join(root, session, name), 'utf8'));
|
|
72
|
+
if (capture !== null)
|
|
73
|
+
captures.push(capture);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return captures.sort((a, b) => a.time - b.time || (a.name < b.name ? -1 : 1));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* since 以降で最新の先頭ページから nextCursor null まで辿る。古い連鎖へは戻らない。戻ると、hook が止まった後や
|
|
80
|
+
* 取り直しが途中で止まった後に、以前の規約で成功してしまうため。
|
|
81
|
+
* @param captures 時刻の昇順の捕捉
|
|
82
|
+
* @param input cursor を含まない search_rules の入力
|
|
83
|
+
* @param since この時刻 (epoch ms) より前の捕捉を使わない
|
|
84
|
+
*/
|
|
85
|
+
function walkChain(captures, input, since) {
|
|
86
|
+
const key = inputKey(input);
|
|
87
|
+
const matching = captures.filter((row) => row.time >= since && inputKey(row.input) === key);
|
|
88
|
+
const first = matching.findLast((row) => row.input.cursor === undefined);
|
|
89
|
+
if (first === undefined) {
|
|
90
|
+
throw new Error(`No captured response since ${since} for ${key}. Check that the Shinaps hook is enabled and trusted, that this command runs in the same Git root as the fetch (or pass --captures), and that --input is the exact input of the fetch. Then fetch again from the first page.`);
|
|
91
|
+
}
|
|
92
|
+
const sessionRows = matching.filter((row) => row.session === first.session);
|
|
93
|
+
const pages = [];
|
|
94
|
+
let currentIndex = sessionRows.indexOf(first);
|
|
95
|
+
for (;;) {
|
|
96
|
+
const { page } = sessionRows[currentIndex];
|
|
97
|
+
if (page === null) {
|
|
98
|
+
throw new Error(`Incomplete chain: unusable response for ${key}. Fetch again from the first page.`);
|
|
99
|
+
}
|
|
100
|
+
pages.push(page);
|
|
101
|
+
if (page.nextCursor === null)
|
|
102
|
+
return pages;
|
|
103
|
+
const after = currentIndex;
|
|
104
|
+
const nextIndex = sessionRows.findIndex((row, index) => index > after && row.input.cursor === page.nextCursor && row.page !== null);
|
|
105
|
+
if (nextIndex === -1) {
|
|
106
|
+
throw new Error(`Incomplete chain: missing cursor ${page.nextCursor} for ${key}. Fetch again from the first page.`);
|
|
107
|
+
}
|
|
108
|
+
currentIndex = nextIndex;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* @param source.captures 時刻の昇順の捕捉
|
|
113
|
+
* @param source.inputs search_rules の入力。複数のときは id の和を取る
|
|
114
|
+
* @param source.since この時刻 (epoch ms) より前の捕捉を使わない
|
|
115
|
+
* @param source.excludeLabels これらの label を持つ規約を除外する(理由を残す)
|
|
116
|
+
*/
|
|
117
|
+
export function buildSnapshot(source) {
|
|
118
|
+
const byId = new Map();
|
|
119
|
+
for (const input of source.inputs) {
|
|
120
|
+
for (const page of walkChain(source.captures, input, source.since)) {
|
|
121
|
+
for (const rule of [...page.userRules, ...page.officialRules]) {
|
|
122
|
+
const seen = byId.get(rule.id);
|
|
123
|
+
if (seen !== undefined && !isDeepStrictEqual(seen, rule)) {
|
|
124
|
+
throw new Error(`Rule ${rule.id} was captured with different content. Fetch again.`);
|
|
125
|
+
}
|
|
126
|
+
byId.set(rule.id, rule);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const rules = [];
|
|
131
|
+
const excluded = [];
|
|
132
|
+
for (const rule of [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : 1))) {
|
|
133
|
+
const label = rule.labels?.find((slug) => source.excludeLabels?.includes(slug) === true);
|
|
134
|
+
if (label === undefined)
|
|
135
|
+
rules.push(rule);
|
|
136
|
+
else
|
|
137
|
+
excluded.push({ id: rule.id, reason: `label ${label} is on an empty axis` });
|
|
138
|
+
}
|
|
139
|
+
return { rules, excluded };
|
|
140
|
+
}
|
|
141
|
+
const partText = (rules) => `${JSON.stringify(rules, null, 1)}\n`;
|
|
142
|
+
const checksText = (rules) => `${JSON.stringify({ rules: rules.filter((rule) => (rule.checks?.length ?? 0) > 0).map((rule) => ({ id: rule.id, checks: rule.checks })) }, null, 1)}\n`;
|
|
143
|
+
const contentHashOf = (rules) => sha256(JSON.stringify(canonical(rules)));
|
|
144
|
+
export function splitParts(rules) {
|
|
145
|
+
const parts = [];
|
|
146
|
+
for (const rule of rules) {
|
|
147
|
+
const last = parts.at(-1);
|
|
148
|
+
if (last !== undefined && Buffer.byteLength(partText([...last, rule])) <= PART_MAX_BYTES) {
|
|
149
|
+
last.push(rule);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
parts.push([rule]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return parts;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* @param snapshot buildSnapshot の結果
|
|
159
|
+
* @param inputs manifest に残す search_rules の入力
|
|
160
|
+
* @param out 出力先のディレクトリ
|
|
161
|
+
*/
|
|
162
|
+
export async function writeSnapshot(snapshot, inputs, out) {
|
|
163
|
+
await rm(join(out, 'parts'), { recursive: true, force: true });
|
|
164
|
+
await mkdir(join(out, 'parts'), { recursive: true });
|
|
165
|
+
const parts = [];
|
|
166
|
+
for (const [index, rules] of splitParts(snapshot.rules).entries()) {
|
|
167
|
+
const file = `parts/part-${String(index + 1).padStart(3, '0')}.json`;
|
|
168
|
+
const text = partText(rules);
|
|
169
|
+
await writeFile(join(out, file), text);
|
|
170
|
+
parts.push({ file, sha256: sha256(text) });
|
|
171
|
+
}
|
|
172
|
+
const lintChecks = checksText(snapshot.rules);
|
|
173
|
+
await writeFile(join(out, 'lint-checks.json'), lintChecks);
|
|
174
|
+
const manifest = {
|
|
175
|
+
contentHash: contentHashOf(snapshot.rules),
|
|
176
|
+
inputs,
|
|
177
|
+
ruleIds: snapshot.rules.map((rule) => rule.id),
|
|
178
|
+
excluded: snapshot.excluded,
|
|
179
|
+
parts,
|
|
180
|
+
lintChecksSha256: sha256(lintChecks),
|
|
181
|
+
};
|
|
182
|
+
await writeFile(join(out, 'manifest.json'), `${JSON.stringify(manifest, null, 1)}\n`);
|
|
183
|
+
return manifest;
|
|
184
|
+
}
|
|
185
|
+
/** snapshot のファイルが作成時から変わっていないことを確かめ、manifest を返す。 */
|
|
186
|
+
export async function verifySnapshot(manifestPath) {
|
|
187
|
+
const directory = dirname(manifestPath);
|
|
188
|
+
const manifest = await readJson(manifestPath);
|
|
189
|
+
const rules = [];
|
|
190
|
+
for (const part of manifest.parts) {
|
|
191
|
+
const text = await readFile(join(directory, part.file), 'utf8');
|
|
192
|
+
if (sha256(text) !== part.sha256)
|
|
193
|
+
throw new Error(`Snapshot part changed: ${part.file}. Run rule-snapshot again.`);
|
|
194
|
+
const partRules = JSON.parse(text);
|
|
195
|
+
if (!Array.isArray(partRules))
|
|
196
|
+
throw new Error(`Snapshot part is not a list: ${part.file}. Run rule-snapshot again.`);
|
|
197
|
+
rules.push(...partRules);
|
|
198
|
+
}
|
|
199
|
+
if (contentHashOf(rules) !== manifest.contentHash)
|
|
200
|
+
throw new Error('Snapshot content changed. Run rule-snapshot again.');
|
|
201
|
+
if (!isDeepStrictEqual(rules.map((rule) => rule.id), manifest.ruleIds)) {
|
|
202
|
+
throw new Error('manifest.json ruleIds do not match the parts. Run rule-snapshot again.');
|
|
203
|
+
}
|
|
204
|
+
const checks = await readFile(join(directory, 'lint-checks.json'), 'utf8');
|
|
205
|
+
if (sha256(checks) !== manifest.lintChecksSha256)
|
|
206
|
+
throw new Error('lint-checks.json changed. Run rule-snapshot again.');
|
|
207
|
+
return manifest;
|
|
208
|
+
}
|
|
209
|
+
/** 24 時間より古い捕捉だけを消す。他の session の取得中の捕捉は残す。 */
|
|
210
|
+
async function removeOldCaptures(root, now) {
|
|
211
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
212
|
+
if (!entry.isDirectory())
|
|
213
|
+
continue;
|
|
214
|
+
const directory = join(root, entry.name);
|
|
215
|
+
for (const name of await readdir(directory)) {
|
|
216
|
+
if (now - Number.parseInt(name, 10) > CAPTURE_MAX_AGE_MS)
|
|
217
|
+
await rm(join(directory, name), { force: true });
|
|
218
|
+
}
|
|
219
|
+
if ((await readdir(directory)).length === 0)
|
|
220
|
+
await rm(directory, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// plugin の hook は依存なしで動かすため同じ処理を持つ。同じ保存先になることは test/rule-snapshot.test.ts の通しの試験が確かめる。
|
|
224
|
+
function projectRoot(cwd) {
|
|
225
|
+
for (let directory = cwd;; directory = dirname(directory)) {
|
|
226
|
+
if (existsSync(join(directory, '.git')))
|
|
227
|
+
return directory;
|
|
228
|
+
if (dirname(directory) === directory)
|
|
229
|
+
return cwd;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
export async function ruleSnapshot(args) {
|
|
233
|
+
const since = Number(flagValues(args, '--since')[0]);
|
|
234
|
+
const inputPaths = flagValues(args, '--input');
|
|
235
|
+
const out = flagValues(args, '--out')[0];
|
|
236
|
+
if (!Number.isFinite(since) || inputPaths.length === 0 || out === undefined) {
|
|
237
|
+
throw new Error(`Usage: ${RULE_SNAPSHOT_COMMAND}`);
|
|
238
|
+
}
|
|
239
|
+
const rawInputs = await Promise.all(inputPaths.map((path) => readJson(path)));
|
|
240
|
+
const inputs = rawInputs.filter(isRecord);
|
|
241
|
+
if (inputs.length !== rawInputs.length || inputs.some((input) => input.fields !== 'full')) {
|
|
242
|
+
throw new Error(`--input must be an object with fields "full": ${JSON.stringify(rawInputs)}`);
|
|
243
|
+
}
|
|
244
|
+
const root = flagValues(args, '--captures')[0] ??
|
|
245
|
+
join(projectRoot(process.cwd()), '.shinaps/work/rule-captures');
|
|
246
|
+
let snapshot;
|
|
247
|
+
try {
|
|
248
|
+
snapshot = buildSnapshot({
|
|
249
|
+
captures: await readCaptures(root),
|
|
250
|
+
inputs,
|
|
251
|
+
since,
|
|
252
|
+
excludeLabels: flagValues(args, '--exclude-label'),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
throw new Error(`${errorMessage(error)} Captures: ${root}`, { cause: error });
|
|
257
|
+
}
|
|
258
|
+
const manifest = await writeSnapshot(snapshot, inputs, resolve(out));
|
|
259
|
+
await removeOldCaptures(root, Date.now());
|
|
260
|
+
console.log(`Shinaps rule snapshot: ${manifest.ruleIds.length} rules, ${manifest.excluded.length} excluded, ${manifest.parts.length} parts, contentHash ${manifest.contentHash} → ${out}`);
|
|
261
|
+
}
|