Skip to content

Commit 95a5f65

Browse files
feat: git ref 指定つき読み込み(worktree 方式)とビューアの差分タブ
- 全コマンド共通の --ref <git ref>。一時 worktree に取り出して解析するので 利用者の作業ツリー・インデックスには触れない - strata diff [dir] --ref <base>..<head>(head 省略で作業ツリーと比較) - ビューアに「差分」タブ。/refs でブランチ・タグを列挙し、/diff で 2 つの ref を 比較。head のコミットから PR 番号を復元できればリンクを出す - ref は英数字始まりのみ許可し、git のオプションに化ける形を弾く realpath に揃えてから相対化しないと、macOS の /var → /private/var のずれで worktree の外(= 元の作業ツリー)を解析してしまう。テストで検出したので明示的に封じた。
1 parent f2c8b30 commit 95a5f65

13 files changed

Lines changed: 651 additions & 19 deletions

File tree

docs/SPEC.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,27 @@ $ strata trace . 'frontend/src/api/client.ts#fetchUser'
668668
- **未使用の可能性がある API 一覧**(棚卸し候補)
669669
- **循環依存一覧**
670670

671-
### 9.4 スタックトレース解析(ビューア)
671+
### 9.4 ref 指定つき読み込みと差分ビュー
672+
673+
全コマンド共通の `--ref <git ref>` で、作業ツリーではなくその ref の内容を解析する。
674+
675+
- **git worktree 方式**: `git worktree add --detach` で一時ディレクトリに取り出して解析し、
676+
終了時に必ず撤去する。clone しないので速く、利用者の作業ツリー・インデックスに触れない
677+
- ref は `rev-parse --verify <ref>^{commit}` で解決する。英数字始まりのみ許可し、
678+
`-` 始まり(git のオプションに化ける)は形式段階で弾く。git 呼び出しはすべてシェル非経由
679+
- 解析対象がリポジトリのサブディレクトリのときは、worktree 側の同じサブディレクトリを見る。
680+
**パスは両方 realpath に揃えてから相対化する**(macOS の `/var``/private/var` のずれで
681+
worktree の外を指し、元の作業ツリーを解析してしまう事故があった)
682+
- `strata diff [dir] --ref <base>..<head>` で 2 つの ref を直接比較する。head を省くと作業ツリーと比べる
683+
684+
ビューアの「差分」タブ(`#diff`)は同じ仕組みをサーバー経由で使う:
685+
686+
- `/refs` — 比較に選べるブランチ・タグ・現在の HEAD(`origin/HEAD` のような symref は除く)
687+
- `/diff?base=&head=` — 2 つの ref を解析して §12 の差分を返す。head 省略時は作業ツリー。
688+
head のコミットメッセージから PR 番号を復元できた場合は、リモート URL と併せて PR リンクを出す
689+
- 循環は増=悪(ローズ)/減=良(緑)、依存の増減はそれ自体に良し悪しが無いので中立色で示す
690+
691+
### 9.5 スタックトレース解析(ビューア)
672692

673693
ヘッダの「📋 トレース」から、panic・エラーログ・スタックトレースを貼り付けると:
674694

docs/guide/viewer.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,24 @@ proto の RPC・HTTP エンドポイント・GraphQL フィールドを 1 つの
7878
<img alt="エントリーポイントタブ" src="{{ site.baseurl }}/assets/shots/entries-light.png">
7979
</picture>
8080

81+
## 差分タブ — 「この変更で構造は悪くなっていないか?」
82+
83+
2 つの git ref(ブランチ / タグ)を選んで比較し、**サービス依存の増減**
84+
**新規に発生した / 解消された循環**だけを見せます。比較先を「作業ツリー」にすれば、
85+
まだコミットしていない変更の影響も確認できます。
86+
87+
ref はそれぞれ一時的な `git worktree` に取り出して解析するので、**いま編集中のファイルには触れません**
88+
比較先のコミットメッセージから PR 番号を検出できた場合は、GitHub の PR へのリンクが出ます。
89+
90+
同じことは CLI でもできます(CI で使う場合はこちら):
91+
92+
```sh
93+
strata diff . --ref main..feature/new-api # 2 つの ref を比較
94+
strata diff . --ref origin/main # ref と作業ツリーを比較
95+
```
96+
97+
新規の循環が生まれたときだけ exit 1 になるので、PR の CI にそのまま置けます。
98+
8199
## ソースビューア — 「実際のコードで確認したい」
82100

83101
- シンタックスハイライト、⌘クリックで定義ジャンプ、識別子ホバーで定義のプレビュー

docs/reference/cli.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,36 @@ strata <command> [dir|model.json] [options]
1919
| `check` | CI 検査(循環 / 禁止依存 / しきい値) | `--baseline``--update-baseline``--sarif FILE` |
2020
| `trace` | 関数から下流 / 上流を辿る | `--depth N` |
2121
| `report` | Markdown レポート(mermaid 図つき) | `-o report.md` |
22-
| `diff` | 2 つの状態を比較する | `--base <ref>` |
22+
| `diff` | 2 つの状態を比較する | `--ref <base>..<head>` |
2323
| `metrics` | サービス結合度(Ca / Ce / 不安定度) | |
2424
| `init` | `strata.config.json` の雛形を作る | `--force` |
2525

26+
## 共通オプション: `--ref`
27+
28+
`--ref <git ref>` を付けると、作業ツリーではなく**その ref の内容**を解析します。
29+
ref は一時的な `git worktree` に取り出すので、いま編集中のファイルには一切触れません。
30+
31+
```sh
32+
# main ブランチ時点の構造を見る(手元の変更はそのまま)
33+
strata serve . --ref main
34+
35+
# リリースタグ時点の循環を検査する
36+
strata check . --ref v1.2.0
37+
```
38+
39+
`diff` では `--ref` に範囲を渡せます。
40+
41+
```sh
42+
# 2 つの ref を比較する
43+
strata diff . --ref main..feature/new-api
44+
45+
# ref と「いまの作業ツリー」を比較する(head を省略)
46+
strata diff . --ref main
47+
```
48+
49+
ビューアの「差分」タブからも同じ比較ができ、比較先のコミットから PR 番号を検出できた場合は
50+
GitHub の PR へのリンクを表示します。
51+
2652
## よく使う組み合わせ
2753

2854
```sh
@@ -34,6 +60,9 @@ strata export . -o architecture.html
3460

3561
# CI: 循環だけは絶対に増やさない
3662
strata check . --baseline
63+
64+
# CI: この PR で構造が悪化していないか(新規循環があれば exit 1)
65+
strata diff . --ref origin/main
3766
```
3867

3968
## 終了コード

src/cli.ts

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { diffModels } from './diff.ts';
1212
import { buildReport } from './report.ts';
1313
import { serve } from './server.ts';
1414
import { exportHtml } from './export.ts';
15+
import { scanAtRef, splitRefRange } from './gitref.ts';
1516
import { parseGraph, TOOL_VERSION } from './model.ts';
1617
import type { Graph, GNode } from './model.ts';
1718

@@ -29,10 +30,15 @@ const HELP = `Strata — 多言語・マイクロサービス対応の依存関
2930
strata report [dir|model.json] [-o report.md] アーキテクチャレポート(mermaid + Markdown)を出力
3031
strata metrics [dir|model.json] [--json] サービス結合度(Ca/Ce/不安定度)を算出
3132
strata diff <old> <new> [--json] 2 モデルを比較(依存増減・新規/解消の循環)
33+
[dir] --ref <base>..<head> 2 つの git ref を直接比較(head 省略で作業ツリー)
3234
strata trace [dir|model.json] <関数名/ID> [--up] [--depth N] コールツリーを表示
3335
strata init [dir] [--force] strata.config.json の雛形を作成
3436
strata --version バージョンを表示(不具合報告時に添えてください)
3537
38+
共通:
39+
--ref <git ref> 作業ツリーではなく、その ref の内容を解析する
40+
(一時 worktree に取り出すので、いま編集中のファイルには触れない)
41+
3642
対応: Go / TypeScript / JavaScript / Python / Elixir / Protocol Buffers(gRPC)
3743
設定: ワークスペースルートの strata.config.json(docs/SPEC.md 参照)
3844
`;
@@ -52,7 +58,7 @@ function parseArgs(argv: string[]): Args {
5258
}
5359
for (; i < argv.length; i++) {
5460
const a = argv[i];
55-
if (a === '-o' || a === '--out' || a === '--port' || a === '--depth' || a === '--baseline') {
61+
if (a === '-o' || a === '--out' || a === '--port' || a === '--depth' || a === '--baseline' || a === '--ref') {
5662
args.options.set(a.replace(/^-+/, ''), argv[++i] ?? '');
5763
} else if (a === '-v') {
5864
args.options.set('version', true); // よく使われる短縮形だけ受ける
@@ -65,12 +71,19 @@ function parseArgs(argv: string[]): Args {
6571
return args;
6672
}
6773

68-
function loadModel(input: string | undefined): Graph {
74+
function loadModel(input: string | undefined, ref?: string): Graph {
6975
const target = input ?? '.';
7076
if (target.endsWith('.json') && fs.existsSync(target) && fs.statSync(target).isFile()) {
77+
if (ref) throw new Error('--ref は model.json ではなくディレクトリに対して指定してください');
7178
return parseGraph(fs.readFileSync(target, 'utf8'), target);
7279
}
73-
return scan(target);
80+
return ref ? scanAtRef(target, ref) : scan(target);
81+
}
82+
83+
/** コマンド共通の `--ref <git ref>`。指定がなければ undefined。 */
84+
function refOf(args: Args): string | undefined {
85+
const ref = args.options.get('ref');
86+
return typeof ref === 'string' && ref !== '' ? ref : undefined;
7487
}
7588

7689
function labelOf(model: Graph, id: string): string {
@@ -234,7 +247,9 @@ function main(): void {
234247

235248
switch (command) {
236249
case 'scan': {
237-
const model = scan(args.positional[0] ?? '.');
250+
const ref = refOf(args);
251+
const target = args.positional[0] ?? '.';
252+
const model = ref ? scanAtRef(target, ref) : scan(target);
238253
const out = args.options.get('o') ?? args.options.get('out');
239254
const json = JSON.stringify(model, null, 2);
240255
if (typeof out === 'string' && out !== '') {
@@ -285,18 +300,18 @@ function main(): void {
285300
case 'serve': {
286301
const input = args.positional[0] ?? '.';
287302
const port = Number(args.options.get('port') ?? 7333);
288-
serve(input, port, { watch: args.options.has('watch') });
303+
serve(input, port, { watch: args.options.has('watch'), ...(refOf(args) ? { ref: refOf(args)! } : {}) });
289304
break;
290305
}
291306
case 'export': {
292-
const model = loadModel(args.positional[0]);
307+
const model = loadModel(args.positional[0], refOf(args));
293308
const out = (args.options.get('o') as string) || (args.options.get('out') as string) || 'report.html';
294309
fs.writeFileSync(out, exportHtml(model));
295310
console.log(`書き出しました: ${path.resolve(out)}`);
296311
break;
297312
}
298313
case 'check': {
299-
const model = loadModel(args.positional[0]);
314+
const model = loadModel(args.positional[0], refOf(args));
300315
const violations = checkRules(model, model.rules);
301316
// SARIF 出力(GitHub Code Scanning 連携)。テキスト出力の代わりに構造化して出す
302317
if (args.options.has('sarif')) {
@@ -339,14 +354,26 @@ function main(): void {
339354
break;
340355
}
341356
case 'diff': {
342-
if (args.positional.length < 2) {
357+
// --ref base..head を渡すと、その 2 つの ref を一時 worktree に取り出して比較する
358+
// (head を省くと作業ツリーと比べる)。model.json を 2 つ渡す従来の形も残す。
359+
const spec = refOf(args);
360+
const range = spec ? splitRefRange(spec) : null;
361+
let a: Graph;
362+
let b: Graph;
363+
if (spec) {
364+
const dir = args.positional[0] ?? '.';
365+
a = scanAtRef(dir, range ? range.base : spec);
366+
b = range ? scanAtRef(dir, range.head) : loadModel(dir);
367+
} else if (args.positional.length >= 2) {
368+
a = loadModel(args.positional[0]);
369+
b = loadModel(args.positional[1]);
370+
} else {
343371
console.error('使い方: strata diff <old: dir|model.json> <new: dir|model.json> [--json]');
344-
console.error(' 各 git ref で strata scan -o した model.json を渡すと、変化を比較できます');
372+
console.error(' strata diff [dir] --ref <base>..<head> [--json] 2 つの git ref を比較');
373+
console.error(' strata diff [dir] --ref <base> [--json] ref と作業ツリーを比較');
345374
process.exitCode = 1;
346375
return;
347376
}
348-
const a = loadModel(args.positional[0]);
349-
const b = loadModel(args.positional[1]);
350377
const d = diffModels(a, b);
351378
if (args.options.has('json')) {
352379
console.log(JSON.stringify(d, null, 2));
@@ -375,7 +402,7 @@ function main(): void {
375402
break;
376403
}
377404
case 'metrics': {
378-
const model = loadModel(args.positional[0]);
405+
const model = loadModel(args.positional[0], refOf(args));
379406
const metrics = computeServiceMetrics(model);
380407
if (args.options.has('json')) {
381408
console.log(JSON.stringify(metrics, null, 2));
@@ -401,7 +428,7 @@ function main(): void {
401428
break;
402429
}
403430
case 'report': {
404-
const model = loadModel(args.positional[0]);
431+
const model = loadModel(args.positional[0], refOf(args));
405432
const md = buildReport(model);
406433
const out = args.options.get('o') ?? args.options.get('out');
407434
if (typeof out === 'string' && out !== '') {
@@ -420,7 +447,7 @@ function main(): void {
420447
}
421448
const query = args.positional[args.positional.length - 1];
422449
const input = args.positional.length >= 2 ? args.positional[0] : '.';
423-
const model = loadModel(input);
450+
const model = loadModel(input, refOf(args));
424451
let target = model.nodes.find((n) => n.id === query);
425452
if (!target) {
426453
const matches = model.nodes.filter(

src/gitref.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// git ref を一時 worktree に取り出して解析する(docs/SPEC.md §9.3)。
2+
//
3+
// 作業ツリーを汚さずに「別ブランチ / タグ / コミットの依存モデル」を得るための仕組み。
4+
// git worktree は .git を共有するので clone より速く、ディスクも食わない。
5+
// git はすべて execFile 系(シェル非経由)で呼ぶ。
6+
7+
import { execFileSync } from 'node:child_process';
8+
import * as fs from 'node:fs';
9+
import * as os from 'node:os';
10+
import * as path from 'node:path';
11+
import { scan } from './scan.ts';
12+
import type { Graph } from './model.ts';
13+
14+
/**
15+
* ref として受け付ける形。シェルは経由しないが、`-` 始まりは git のオプションと
16+
* 解釈されうるので弾く(`--upload-pack=...` 型の混入を防ぐ)。
17+
*/
18+
const REF_PATTERN = /^[A-Za-z0-9][\w./@^~+-]{0,200}$/;
19+
20+
function git(cwd: string, args: string[]): string {
21+
return execFileSync('git', ['-C', cwd, ...args], {
22+
encoding: 'utf8',
23+
maxBuffer: 16 * 1024 * 1024,
24+
stdio: ['ignore', 'pipe', 'pipe'],
25+
}).trim();
26+
}
27+
28+
/**
29+
* dir が属する git リポジトリのルート。git 管理外なら null。
30+
* git はシンボリックリンクを解決した実パスを返すので(macOS の /var → /private/var 等)、
31+
* 呼び出し側のパスと突き合わせるときは両方を実パスに揃えること。
32+
*/
33+
export function repoRootOf(dir: string): string | null {
34+
try {
35+
return git(dir, ['rev-parse', '--show-toplevel']);
36+
} catch {
37+
return null;
38+
}
39+
}
40+
41+
/** シンボリックリンクを解決した絶対パス。解決できなければ path.resolve の結果。 */
42+
function realPath(p: string): string {
43+
try {
44+
return fs.realpathSync(path.resolve(p));
45+
} catch {
46+
return path.resolve(p);
47+
}
48+
}
49+
50+
/** ref をコミット SHA に解決する。解決できなければ理由つきで投げる。 */
51+
export function resolveRef(root: string, ref: string): string {
52+
if (!REF_PATTERN.test(ref)) {
53+
throw new Error(`ref の形式が不正です: ${ref}(英数字で始まる git の参照名を指定してください)`);
54+
}
55+
try {
56+
return git(root, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]);
57+
} catch {
58+
throw new Error(`ref を解決できません: ${ref}(このリポジトリに存在しない、または未フェッチ)`);
59+
}
60+
}
61+
62+
/**
63+
* ref の内容を一時 worktree に取り出し、fn に「dir に対応するパス」を渡す。
64+
* dir がリポジトリのサブディレクトリなら、worktree 側の同じサブディレクトリを渡す。
65+
* fn の成否にかかわらず worktree は必ず片付ける。
66+
*/
67+
export function withWorktree<T>(dir: string, ref: string, fn: (checkout: string) => T): T {
68+
const root = repoRootOf(dir);
69+
if (root === null) throw new Error(`git リポジトリではありません: ${dir}(--ref は git 管理下でのみ使えます)`);
70+
const sha = resolveRef(root, ref);
71+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'strata-ref-'));
72+
const checkout = path.join(tmp, 'tree');
73+
try {
74+
git(root, ['worktree', 'add', '--detach', '--quiet', checkout, sha]);
75+
} catch (err) {
76+
fs.rmSync(tmp, { recursive: true, force: true });
77+
throw new Error(`worktree を作成できません(${ref}): ${(err as Error).message.split('\n')[0]}`);
78+
}
79+
// 実パスに揃えてから相対化する。揃えないと macOS の /var → /private/var のずれで
80+
// `../..` を含む相対パスになり、worktree ではなく元の作業ツリーを解析してしまう。
81+
const sub = path.relative(realPath(root), realPath(dir));
82+
try {
83+
if (sub.startsWith('..') || path.isAbsolute(sub)) {
84+
throw new Error(`解析対象がリポジトリの外にあります: ${dir}`);
85+
}
86+
return fn(sub === '' ? checkout : path.join(checkout, sub));
87+
} finally {
88+
try {
89+
git(root, ['worktree', 'remove', '--force', checkout]);
90+
} catch {
91+
// worktree の登録解除に失敗しても、実体は下で消す
92+
}
93+
fs.rmSync(tmp, { recursive: true, force: true });
94+
try {
95+
git(root, ['worktree', 'prune']);
96+
} catch {
97+
// 後始末の失敗は解析結果に影響しない
98+
}
99+
}
100+
}
101+
102+
/** ref 時点のソースを解析してモデルを返す。モデルには解析した ref を記録する。 */
103+
export function scanAtRef(dir: string, ref: string): Graph {
104+
const model = withWorktree(dir, ref, (checkout) => scan(checkout));
105+
model.ref = ref;
106+
// root は一時 worktree のパスになるため、利用者が見て意味のある元のパスに戻す
107+
model.root = path.resolve(dir);
108+
return model;
109+
}
110+
111+
/** `base..head` 形式を 2 つの ref に割る。`..` が無ければ null。 */
112+
export function splitRefRange(spec: string): { base: string; head: string } | null {
113+
const i = spec.indexOf('..');
114+
if (i < 0) return null;
115+
const base = spec.slice(0, i);
116+
const head = spec.slice(i + 2);
117+
return base !== '' && head !== '' ? { base, head } : null;
118+
}

src/model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export interface Graph {
9696
warnings: string[];
9797
rules?: ForbiddenRule[]; // strata.config.json の forbidden(あれば埋め込む)
9898
thresholds?: Thresholds; // strata.config.json の thresholds(あれば埋め込む)
99+
ref?: string; // --ref で解析したときの git ref(作業ツリーを見たときは未設定)
99100
}
100101

101102
/** ツールのバージョン。リリースタグと package.json の version と必ず一致させる

0 commit comments

Comments
 (0)