spec

star 0

Excel UniDiff CLI(euni)の正式仕様。要件確認、実装判断、テスト設計、CI運用方針の参照時に使う。Unicode正規化差分(NFC/NFD)とGit設定drift対策を含む。

takuto-tanaka-4digit By takuto-tanaka-4digit schedule Updated 2/27/2026

name: spec description: Excel UniDiff CLI(euni)の正式仕様。要件確認、実装判断、テスト設計、CI運用方針の参照時に使う。Unicode正規化差分(NFC/NFD)とGit設定drift対策を含む。

Excel UniDiff CLI(euni)仕様書

  • 文書ID: UGC-SPEC-001
  • バージョン: v1.0
  • 作成日: 2026-02-25
  • 対象: macOS / Linux / Windows の Git リポジトリ(submodule 含む)

1. 背景と目的

macOS ではファイルシステムと Git の Unicode 正規化差(NFC/NFD)により、以下が起こる。

  • discard しても差分が消えない
  • 実質差分なしなのに git status が dirty
  • 自動化(Cursor/Codex/CI)が差分処理で停滞

本仕様の目的は、Runner 非依存で Unicode 差分を統一管理し、開発・CI どちらでも再現可能に抑止すること。

2. 命名(共通化)

  • CLI リポジトリ名: excel-unidiff-cli
  • 実行バイナリ名: euni
  • 互換エイリアス: excel-unidiff(symlink/alias 推奨)
  • Go module: github.com/<org>/excel-unidiff-cli

ms-docs 固有名は使わない。他リポジトリへ横展開前提。

3. スコープ

3.1 対象

  • Git root リポジトリ
  • 全 submodule(--recursive 指定時)
  • root repo 内ファイル走査(常に再帰)
  • Git subtree 配下のファイル群(対象 repo 配下として解析対象)
  • root 配下で検出される nested Git repository(git rev-parse --show-toplevel で判定、--recursive=true 時)
  • Unicode 設定 drift 検知・修復
  • NFC 衝突・結合文字(combining mark)検査

3.2 非対象(v1)

  • ファイル名自動 rename(破壊リスク高)
  • git reset --hard / git clean -fd の自動実行
  • git worktree / --separate-git-dir で gitdir が --repo 境界外の構成
  • Git 以外の VCS

4. 正常系ポリシー(基準)

Git 公式説明準拠:

  • core.precomposeUnicode=true は macOS で分解済み文字列を再合成し、他OS共有に有効
  • core.protectHFS=true は macOS で有効化推奨

標準基準:

  • root(macOS):
    • core.precomposeunicode=true
    • core.protecthfs=true
  • root(Linux/Windows/WSL):
    • core.precomposeunicode=false
  • submodule:
    • デフォルトは root 準拠
    • 例外は policy で明示(例: 特定 submodule を false 固定)

補足:

  • CI が Linux/Windows で動く場合、CI 側でも同じ policy で check を強制しないと再発する。
  • 「mac だけ設定」で十分ではない。

5. CLI 要件

5.1 コマンド一覧

  1. euni check
  2. euni apply
  3. euni doctor
  4. euni scan
  5. euni init-policy
  6. euni version

5.2 共通オプション

  • --repo <path>: 対象 root(省略時 .
  • --recursive: 既定 falsetrue のとき submodule / nested repo を追加探索(subtree は root 走査で常時対象)
  • --policy <path>: policy YAML パス(check|apply|doctor のみ。省略時 <repo>/.euni.yml
  • --format text|json: 出力形式(check|apply|doctor|scan のみ。デフォルト text
  • --quiet: 進捗ログ抑制(check|apply|doctor|scan のみ。結果レポートは維持)
  • --non-interactive: 非対話実行(プロンプト禁止。現行コマンドでは将来互換のため受理し no-op)
  • --log-file <path>: 監査ログ保存先(check|apply|doctor|scan のみ)

適用表:

option check/apply/doctor/scan init-policy version
--repo 可(生成先root) 不可
--recursive 不可 不可
--policy `check apply doctor` のみ可
--format 不可 不可
--quiet 不可 不可
--non-interactive 不可
--log-file 不可 不可

規約:

  • 非対応オプションを渡した場合は UG009 として exit 2

5.3 対象範囲マトリクス

command --recursive=false(既定) --recursive=true
check root のみ root + submodule + nested repo
apply root のみ root + submodule + nested repo
doctor root のみ root + submodule + nested repo
scan root のみ root + submodule + nested repo
init-policy 対象外 対象外
version 対象外 対象外

補足:

  • subtree は独立 repo ではない。対象 repo 配下ファイルとして自動包含。
  • scan は各対象 repo ごとに追跡/未追跡ファイルを解析し、最終結果を集約出力する。
  • root repo 内のファイル走査は常に再帰。--recursive は追加 repo(submodule/nested)探索の有効化だけを制御する。

5.4 サブコマンド仕様

check

  • 目的: drift + Unicode 健全性検知(非破壊)
  • 挙動:
    • 5.3 の対象範囲に対して expected/actual を比較
    • 対象 repo 配下を走査し、subtree 配下を含めて Unicode 健全性を検査
    • Unicode 検査対象は git ls-files -z(追跡済み)のみ(未追跡は scan/doctor で扱う)
    • drift 一覧出力
    • 設定変更は行わない
    • --fix オプションは提供しない(修復は apply のみ)
    • config 比較は実効 bool 値で実施(未設定は false とみなす)
  • 終了コード:
    • 0: findings なし
    • 1: findings あり(例: drift / Unicode / UG012 / UG013
    • 2: 実行エラー

apply

  • 目的: drift 修復
  • コマンド専用オプション:
    • --dry-run: 変更予定のみ表示(書き込みしない)
    • --repair-unicode-deletes: core.precomposeunicode=false モードで、削除済み追跡パス(staged/worktree)を復旧
  • 挙動:
    • 5.3 の対象範囲に対して drift 項目のみ git config --local で修正
    • --repair-unicode-deletes 指定時、削除済み追跡パスを復旧(core.precomposeunicode=false モード)
      • staged 削除: git restore --staged --worktree
      • worktree のみ削除: git restore --worktree(staged の非削除変更は保持)
    • --repair-unicode-deletes は対象範囲の削除済み追跡パスを一括復旧するため、意図的削除がある場合は使用しない
    • 修正後に check 相当で再評価(drift + Unicode)
    • --dry-run 時は変更予定のみ表示
  • 終了コード:
    • 0: 修正後に findings なし
    • 1: --dry-run で変更予定あり、または findings 残存(例: drift / Unicode / UG012 / UG013 / UG014
    • 2: 実行エラー

doctor

  • 目的: 失敗時の原因診断
  • 挙動:
    • check + scan 相当を統合
    • 原因分類:
      • config drift
      • NFC 衝突
      • combining mark 混在
      • Git 管理外要因
    • 次アクションを1行提案
  • 終了コード:
    • 0: 問題なし
    • 1: 対応が必要な問題あり
    • 2: 実行エラー

scan

  • 目的: Unicode パス健全性解析
  • 挙動:
    • policy は参照しない(--policy 非対応)
    • 5.3 の対象範囲ごとに解析
    • git ls-files -z(追跡済み)を対象に解析
    • git ls-files --others --exclude-standard -z(未追跡)も対象に解析
    • subtree 配下ファイルは対象 repo の追跡/未追跡ファイルとして解析対象に含める
    • 指標:
      • NFC-only 件数
      • NFD-only 件数
      • NFC衝突件数(normalize(NFC) 後に同名化)
      • combining mark 含有パス
  • 終了コード:
    • 0: Unicode 健全性問題なし
    • 1: findings あり(NFC 衝突 / combining mark / UG012
    • 2: 実行エラー

init-policy

  • 目的: policy テンプレート生成
  • コマンド専用オプション:
    • --force: 既存 .euni.yml を上書き
  • 出力: .euni.yml
  • 挙動:
    • 出力先は --repo 直下
    • 既存 .euni.yml がある場合、--force なしでは上書きしない
    • --force 時も .euni.yml は no-follow で検査し、symlink/junction/reparse point の場合は UG010 で失敗
    • --force 時は .euni.yml の実体 canonical path が --repo 境界内であることを必須化(境界外は UG010
    • 生成 YAML はキー順を安定化して出力
  • 終了コード:
    • 0: 生成成功
    • 2: 実行エラー(既存ファイルありで --force なしは UG008

version

  • 目的: バージョン表示
  • 挙動:
    • euni <semver> (<commit>) 形式で標準出力
    • オプション受理は 5.2 適用表に従う
  • 終了コード:
    • 0: 正常終了
    • 2: 実行エラー

5.5 再帰探索仕様(--recursive

既定:

  • --recursive=false。探索対象は --repo で指定した root のみ。

--recursive=true 時の探索順:

  1. --repo で指定された root
  2. .gitmodulesgit submodule status --recursive で取得した submodule(再帰)
  3. root 配下サブフォルダを走査し、git -C <dir> rev-parse --show-toplevel が成功し、かつ top-level が root と異なる nested Git repository
  4. subtree 配下は root のファイル走査対象として自動包含

備考:

  • subtree は独立 repo ではないため、config drift 判定は root 設定に準拠。
  • nested Git repository は独立 repo として個別に drift 判定・修復する。
  • submodule が未初期化/アクセス不能の場合は UG006 として exit 2
  • repo 判定は canonical absolute path で重複排除し、同一 repo は1回だけ処理。
  • repo 重複排除キーは Unicode 正規化を行わない(NFC/NFD を潰さない)lossless な canonical 実体パスを使う。
  • canonical 化(Windows):
    • 区切り文字を \\ 正規化後に内部比較は / へ統一
    • drive letter は大文字化
    • \\\\?\\ / UNC / 8.3 short name を同一 canonical path へ正規化
    • Unicode 文字列は保持し、repo 同一性判定では NFC/NFD 変換しない
  • canonical 化(macOS/Linux):
    • realpath で実体パスへ正規化
    • 区切り文字は / を使用
    • Unicode 文字列は保持し、repo 同一性判定では NFC/NFD 変換しない
  • NFC 正規化比較は policy path 解決(6章)と JSON ソート(5.7)でのみ適用する。
  • 境界比較ルール(Windows):
    • 大文字小文字は無視して比較
    • 文字列前方一致ではなく path segment 境界で比較(/repo/repo-x を区別)
  • 安全境界チェック:
    • --show-toplevel--absolute-git-dir の両方を canonical 化
    • 両方が --repo 境界内であることを必須とする
    • 境界外参照は UG010 として exit 2git worktree / --separate-git-dir 含む)
  • UG001--repo が Git repo ではない/参照不可のときに限定する。
  • nested 候補は .git(file/dir)を持つディレクトリに限定する。
  • .git を持つ候補で rev-parse 失敗時は message 文字列で分類せず UG002 として exit 2(locale 非依存)。
  • シンボリックリンク / junction / reparse point / mount point は再帰対象から除外し UG012 findings を記録する。
  • root 走査時は .git/, .git/modules/, .git/worktrees/ を探索対象から除外する。

5.6 終了コード共通表

  • 0: 正常終了(問題なし、または期待どおりの修復完了)
  • 1: 実務上の検知結果あり(findings。drift/Unicode/管理外要因/policy 適用不能/--dry-run 変更予定)
  • 2: 実行エラー(repo 不正、git 実行失敗、policy 不正、submodule 未初期化/アクセス不能、内部エラー)
  • 優先順位: 実行エラーが1件でもある場合は 2 を優先(findings が同時にあっても 2

5.7 JSON 契約(--format json

出力チャネル:

  • stdout: 単一 JSON オブジェクトのみ(機械可読)
  • stderr: 進捗/警告/エラー詳細
  • --log-file は stderr と同内容を永続化(stdout 契約は維持)

固定フィールド(必須):

  • schemaVersion (string)
  • command (string)
  • repo (string)
  • recursive (boolean)
  • status (string, ok|findings|error)
  • exitCode (integer, 0|1|2)
  • summary (object)
  • results (array)
  • errors (array)
  • startedAt (string, RFC3339)
  • finishedAt (string, RFC3339)

results[] 最低限フィールド:

  • repoPath (string)
  • kind (string, drift|unicode|policy|environment)
  • code (string)
  • message (string)
  • path (string|null)
  • targetType (string, path|configKey|repo)
  • expected (string|null)
  • actual (string|null)
  • action (string|null)
  • details (object|null)

順序・互換:

  • resultsrepoPathkindpathcodeexpectedactual の昇順で安定ソート
  • errorscoderepoPathpath の昇順で安定ソート
  • ソート比較は repoPathpath を canonical 化(5.5)+ NFC 正規化した UTF-8 byte order で実施
  • null は常に最後に並べる(nulls-last)
  • 互換は schemaVersion で管理し、破壊的変更は major を上げる
  • schemaVersion の現行値は "1.0.0"(semver 形式)に固定
  • statusexitCode は固定対応:
    • ok -> 0
    • findings -> 1
    • error -> 2
  • 状態不変条件:
    • status=ok のとき summary.findings=0 かつ summary.errors=0
    • status=findings のとき summary.findings>0 かつ summary.errors=0
    • status=error のとき summary.errors>0 かつ exitCode=2
  • summary 必須フィールド:
    • targetRepos (integer, 実処理対象 repo 数。findings/error が 0 件でも 1 以上になり得る)
    • findings (integer)
    • errors (integer)
    • durationMs (integer)
    • nfcOnly (integer)
    • nfdOnly (integer)
    • nfcCollisions (integer)
    • combiningMarkPaths (integer)
  • summary 不変条件:
    • summary.findings == results.length
    • summary.errors == errors.length
    • summary.targetRepos >= unique(nonNull(results.repoPath ∪ errors.repoPath)).length
  • errors[] 必須フィールド:
    • code (string)
    • message (string)
    • repoPath (string|null)
    • path (string|null)
    • hint (string|null)
  • 分類ルール:
    • results[]: findings(exitCode=1 相当、例: UG004, UG005, UG011, UG012, UG013, UG014
    • errors[]: 実行エラー(exitCode=2 相当、例: UG001, UG002, UG003, UG006, UG007, UG008, UG009, UG010
  • NFC 衝突(UG005)は details に次を必須で持つ:
    • normalizedPath (string)
    • collidingPaths (string[], 2件以上)
  • Schema 検証:
    • schema/euni-report.schema.json(JSON Schema Draft 2020-12)を正本とする
    • schema ルートの $schemahttps://json-schema.org/draft/2020-12/schema に固定
    • top-level と results[] / errors[]additionalProperties: false で固定
    • CI では schema 検証を必須とする(例: ajv validate --spec=draft2020 --strict=true -s schema/euni-report.schema.json -d euni-report.json)
    • CI では「プロセス終了コード」と report.exitCode / report.status の整合を必ず検証する
    • CI では summary 不変条件の整合を必ず検証する

6. Policy ファイル仕様

ファイル名: .euni.yml

version: 1
defaults:
  darwin:
    core.precomposeunicode: true
    core.protecthfs: true
  others:
    core.precomposeunicode: false
submodules:
  confluence-sync:
    core.precomposeunicode: false
nestedRepos:
  tools/legacy-repo:
    core.precomposeunicode: false

要件:

  • キー未指定は defaults を継承
  • submodule 指定は path 名で解決
  • nested repository 例外指定は nestedRepos で path 名指定可能
  • path 解決は比較前に次を適用:
    • repo 相対 path へ正規化
    • 区切り文字を / に統一
    • Unicode NFC 正規化
    • 大文字小文字は区別して比較(Runner 非依存で deterministic)
    • case 差のみの複数候補が存在する場合は UG013 findings として扱い、自動解決しない
  • 不正キーは常に UG007 として exit 2(strict)

7. アーキテクチャ(Go)

7.1 技術

  • Go 1.22+
  • Unicode 正規化: golang.org/x/text/unicode/norm
  • CLI: cobra もしくは標準 flag(実装方針に合わせる)

7.2 パッケージ構成(例)

  • cmd/euni/
  • internal/gitx/(git 実行抽象化)
  • internal/policy/(YAML 読込)
  • internal/drift/(期待値/実測比較)
  • internal/scan/(NFC/NFD 解析)
  • internal/report/(text/json 出力)

7.3 Git 実行ルール

  • 全コマンドは git -C <repo> 経由
  • 対話禁止 (GIT_TERMINAL_PROMPT=0)
  • 変更系は --local のみ(global/system 変更禁止)
  • 変更系は rev-parse --show-toplevel--absolute-git-dir の両方が --repo 境界内である対象に限定

8. VS Code 起動時自動実行仕様

.vscode/tasks.jsonrunOn: folderOpen の task に統合。

推奨コマンド:

euni apply --repo "${workspaceFolder}" --recursive --policy "${workspaceFolder}/.euni.yml" --non-interactive --quiet

運用:

  • 初回は VS Code の自動タスク許可が必要
  • 既存 safe-update と直列化(先に euni apply

9. CI 要件(Runner 非依存)

全 Runner で同一チェック:

  1. check を実行し、終了コードを check_rc として保持(step は継続)
  2. euni-report.json を artifact 保存(always)
  3. schema/euni-report.schema.jsoneuni-report.json を検証し schema_rc を保持(always)
  4. check_rcreport.exitCode/report.status の整合、および summary 不変条件の整合を検証し consistency_rc を保持(always)
  5. 最終終了コードは次の優先順位で決定:
    • schema_rc != 0 または consistency_rc != 0 のとき 2
    • それ以外は check_rc

実装例(bash):

set +e
euni check --repo . --recursive --policy ./.euni.yml --non-interactive --format json > euni-report.json
check_rc=$?
node -e 'process.exit(/^v22\./.test(process.version)?0:2)'
node_ver_rc=$?
npx --yes ajv-cli@5.0.0 validate --spec=draft2020 --strict=true -s schema/euni-report.schema.json -d euni-report.json
schema_rc=$?
node -e 'const r=require("./euni-report.json"); const c=Number(process.argv[1]); const m={ok:0,findings:1,error:2}; const s=new Set([...(r.results||[]).map(x=>x.repoPath).filter(Boolean),...(r.errors||[]).map(x=>x.repoPath).filter(Boolean)]); const okState=(r.status==="ok"?(r.summary.findings===0&&r.summary.errors===0):(r.status==="findings"?(r.summary.findings>0&&r.summary.errors===0):(r.status==="error"?(r.summary.errors>0&&r.exitCode===2):false))); const ok=(r.schemaVersion==="1.0.0")&&(r.exitCode===c)&&(m[r.status]===r.exitCode)&&okState&&(r.summary.findings===r.results.length)&&(r.summary.errors===r.errors.length)&&(r.summary.targetRepos>=s.size); process.exit(ok?0:2)' "$check_rc"
consistency_rc=$?
set -e
# artifact upload step: always
if [ "$node_ver_rc" -ne 0 ] || [ "$schema_rc" -ne 0 ] || [ "$consistency_rc" -ne 0 ]; then exit 2; fi
exit "$check_rc"

実装例(PowerShell):

& euni check --repo . --recursive --policy ./.euni.yml --non-interactive --format json | Out-File -FilePath euni-report.json -Encoding utf8NoBOM
$check_rc = $LASTEXITCODE
$node_ver_rc = if ($PSVersionTable.PSVersion.Major -ge 7 -and (node --version) -match '^v22\.') { 0 } else { 2 }
npx --yes ajv-cli@5.0.0 validate --spec=draft2020 --strict=true -s schema/euni-report.schema.json -d euni-report.json
$schema_rc = $LASTEXITCODE
$report = Get-Content euni-report.json -Raw | ConvertFrom-Json
$statusMap = @{ ok = 0; findings = 1; error = 2 }
$repoSet = [System.Collections.Generic.HashSet[string]]::new()
foreach ($i in $report.results) { if ($null -ne $i.repoPath -and $i.repoPath -ne '') { $null = $repoSet.Add([string]$i.repoPath) } }
foreach ($i in $report.errors) { if ($null -ne $i.repoPath -and $i.repoPath -ne '') { $null = $repoSet.Add([string]$i.repoPath) } }
$okState = if ($report.status -eq 'ok') { ($report.summary.findings -eq 0) -and ($report.summary.errors -eq 0) } elseif ($report.status -eq 'findings') { ($report.summary.findings -gt 0) -and ($report.summary.errors -eq 0) } elseif ($report.status -eq 'error') { ($report.summary.errors -gt 0) -and ($report.exitCode -eq 2) } else { $false }
$ok = ($report.schemaVersion -eq '1.0.0') -and ($report.exitCode -eq $check_rc) -and ($statusMap[$report.status] -eq $report.exitCode) -and $okState -and ($report.summary.findings -eq $report.results.Count) -and ($report.summary.errors -eq $report.errors.Count) -and ($report.summary.targetRepos -ge $repoSet.Count)
$consistency_rc = if ($ok) { 0 } else { 2 }
# artifact upload step: always
if (($node_ver_rc -ne 0) -or ($schema_rc -ne 0) -or ($consistency_rc -ne 0)) { exit 2 }
exit $check_rc

必須要件:

  • Linux / Windows / macOS すべてで実行
  • findings 検知時はジョブ失敗
  • JSON 結果(euni-report.json)を artifact 保存
  • JSON 形式は 5.7 の契約に準拠し、stderr のログ混在を許可しない
  • euni-report.jsonschema/euni-report.schema.json で必ず検証
  • check のプロセス終了コードと JSON exitCode/status の一致を必ず検証
  • CI の検証ツール版を固定(例: Node 22.x, ajv-cli 5.x)

10. エラー設計

  • 例:
    • UG001: repo 不正
    • UG002: git 実行失敗
    • UG003: policy 読込失敗
    • UG004: drift 検知(findings)
    • UG005: NFC 衝突検知(findings)
    • UG006: submodule 未初期化/アクセス不能
    • UG007: policy 不正キー
    • UG008: init-policy 上書き拒否(既存ファイルあり、--force なし)
    • UG009: 非対応オプション指定
    • UG010: 非対応 gitdir 構成(worktree / separate-git-dir)
    • UG011: combining mark 含有パス検知(findings)
    • UG012: Git 管理外要因検知(findings)
    • UG013: policy 適用不能/未解決マッチ検知(findings)
    • UG014: unicode delete repair 関連検知(apply --dry-run --repair-unicode-deletes の予定表示、または apply 実行時の安全skip通知)
  • メッセージ方針:
    • ユーザー向けは平易文
    • 詳細は --format json で machine-readable
    • 各 UG コードは定型 hint を持つ(自動化と運用Runbook参照用)

11. ログと監査

  • --format text: レポートを stdout、進捗/警告/エラー詳細を stderr
  • --format json: stdout は単一 JSON のみ、進捗/警告/エラー詳細は stderr
  • --log-file <path>: stderr ログを永続化(stdout 契約は変更しない)
  • --quiet: 進捗ログのみ抑制。警告/エラーは stderr 出力を維持
  • text 形式のエラー行は [UGxxx] <message> (hint: <text>) 形式を必須とする
  • JSON フィールド契約は 5.7 に準拠

12. テスト要件

12.1 単体

  • policy パース
  • drift 判定
  • NFC/NFD 解析
  • exit code 判定
  • JSON Schema 検証(schema/euni-report.schema.json

12.2 結合

  • root のみ
  • submodule 1段/多段
  • subtree を含む root 配下サブフォルダ再帰
  • nested Git repository 1段/多段
  • macOS/Linux/Windows それぞれで check/apply/doctor/scan
  • core.ignorecase=true/false 両方で policy path 解決

12.3 回帰

  • 「discard しても消えない差分」シナリオ
  • Excel ファイル名(日本語・記号・空白)を含むケース
  • check は追跡済みのみ、scan は追跡済み+未追跡を解析する契約差分

13. 受け入れ基準(DoD)

  1. apply 後、check が全 Runner で成功
  2. root + submodule + nested Git repository で policy と実測が一致
  3. VS Code folderOpen 実行後に Unicode 起因の幽霊差分が再発しない
  4. JSON 出力が CI で解析可能
  5. 破壊的操作なしで復旧可能

14. 導入手順(運用)

  1. excel-unidiff-cli リポジトリを新規作成
  2. CLI をビルド・配布(Release + checksum)
  3. 各対象リポジトリへ .euni.yml 配置
  4. tasks.jsonfolderOpeneuni apply 追加
  5. CI に euni check 追加(findings で exit 1)
  6. Cursor/Codex Skill から doctor/apply を呼ぶ

この仕様は「共通CLIとして横展開する」ことを前提にしている。
実装時は本仕様の DoD を満たすことを完了条件とする。

Install via CLI
npx skills add https://github.com/takuto-tanaka-4digit/excel-unidiff-cli --skill spec
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
takuto-tanaka-4digit
takuto-tanaka-4digit Explore all skills →