petari 0.4.1 → 0.6.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 CHANGED
@@ -48,6 +48,19 @@ petari # 返答 (changes.md) を適用
48
48
  petari の更新で規約文が変わった場合は、`petari init` を再実行すると protocol.md の
49
49
  差分を検出して更新を提案します (slnmix 側の更新は不要)。
50
50
 
51
+ ### 適用が繰り返し失敗するとき
52
+
53
+ 失敗レポート (自動でクリップボードにコピーされます) をそのまま AI に貼り返すのが
54
+ 最短です。レポートには実ファイルの該当箇所の抜粋と、どの行がどう違うかの診断が
55
+ 含まれるため、AI は推測ではなくコピーで SEARCH を直せます。
56
+
57
+ それでも同じ箇所が失敗を繰り返す場合は、**AI に渡したスナップショット (repomix /
58
+ slnmix の出力) が実ファイルとずれている**可能性が高いです。特にチャットサービスは
59
+ 長い添付を要約・検索処理で部分的にしか読まないことがあり、AI が見えなかった箇所を
60
+ もっともらしく補って出力することがあります。SEARCH の空白調整や言い換えを繰り返す
61
+ より、スナップショットを取り直して渡し直すのが確実です (petari 側は空白・インデント・
62
+ 文字コードの差を吸収して照合するため、それらが原因で失敗することはありません)。
63
+
51
64
  ### コマンド
52
65
 
53
66
  | コマンド | 説明 |
@@ -60,7 +73,7 @@ petari の更新で規約文が変わった場合は、`petari init` を再実
60
73
  | `petari init` | プロジェクト初回セットアップ (`--yes` で全提案に同意) |
61
74
 
62
75
  主なオプション: `--dry-run` (検証と差分プレビューのみ) / `--partial` (成功分のみ適用) /
63
- `--root <dir>` / `--yes` / `--clip-report` (失敗レポートをクリップボードへ)
76
+ `--root <dir>` / `--yes` / `--clip-report` (失敗レポートをクリップボードへ。v0.5.0 から既定で自動コピーされるため、config で無効化した場合の個別指定用)
64
77
 
65
78
  ## 特徴
66
79
 
@@ -71,7 +84,13 @@ petari の更新で規約文が変わった場合は、`petari init` を再実
71
84
  結果は `適用 / 済み / 失敗` の 3 状態で表示
72
85
  - **エンコーディング保全**: Shift_JIS / UTF-8 (BOM 有無)・CRLF / LF・末尾改行を完全維持。
73
86
  変更していない行は元のバイト列をそのまま書き戻す (レガシー VB.NET 資産でも安全)
74
- - **失敗レポート**: そのまま AI チャットに貼り返せる形式で出力。再依頼が 1 コピペで済む
87
+ - **規約逸脱への耐性 (寛容パース)**: AI がマーカーの記号数・見出しレベルを崩したり
88
+ 出力全体をコードフェンスで包んでも、厳密パース失敗時に限り安全な範囲で自動補正して
89
+ 解釈する (補正内容は適用前に全件表示。曖昧さが残る場合は採用せずエラーに戻す)
90
+ - **失敗レポート**: そのまま AI チャットに貼り返せる形式で出力し、**既定でクリップボードへ
91
+ 自動コピー** (失敗した時点で貼り返し 1 ペーストの状態。config の
92
+ `clipReportOnFailure: false` で無効化可)。構文エラー時のレポートには規約フォーマットの
93
+ 要点を同梱 (チャット側で規約文が失われていてもレポート 1 枚で再出力を依頼できる)
75
94
  - **git / VS Code がない端末でも差分確認・手修正**: `show` は VS Code がなければ自己完結
76
95
  HTML レポートをブラウザで開き、`show --edit` は同梱の Monaco Editor (VS Code と同じ
77
96
  差分エディタ) で現在のファイルを差分を見ながら直接編集・保存できる
@@ -6,7 +6,7 @@
6
6
  * 制約: slnmix (v0.7.0+) はこのテキストを <instruction>...</instruction> で囲んで
7
7
  * 出力へ連結するため、規約文に <instruction> という文字列を含めないこと。
8
8
  */
9
- export const PROTOCOL_VERSION = 2;
9
+ export const PROTOCOL_VERSION = 3;
10
10
  export const PROTOCOL_TEXT = `<!-- petari protocol v${PROTOCOL_VERSION} -->
11
11
  # コード変更の出力規約 (changes.md)
12
12
 
@@ -76,4 +76,15 @@ new code
76
76
  7. 対象ファイルが日本語 Shift_JIS の場合、絵文字など Shift_JIS で表現できない文字を
77
77
  コード中に入れない
78
78
  8. changes.md 以外の出力 (前置き・後書き) は最小限にする
79
+
80
+ ## 出力前の自己チェック
81
+
82
+ 出力を確定する前に、次の 4 点を必ず確認してください。
83
+
84
+ 1. 出力は「## CHANGES」の行から始まっているか
85
+ 2. すべてのマーカー行が行頭にあり、綴りが正確か
86
+ (<<<<<<< と >>>>>>> は記号 7 個 + 半角スペース + 語、区切りは = 7 個のみの行)
87
+ 3. 各 SEARCH の内容は、提供された現在のファイルからの正確なコピーか
88
+ (記憶をもとに再構成していないか)
89
+ 4. 同じファイルの FILE セクションが重複していないか
79
90
  `;
@@ -8,14 +8,14 @@ import { join, resolve } from "node:path";
8
8
  import { parseArgs } from "node:util";
9
9
  import { invalidPathReason, planChangeSet, } from "../core/applier.js";
10
10
  import { PRESENCE_STAGE_LABEL } from "../core/matcher.js";
11
- import { parseChanges } from "../core/parser.js";
11
+ import { parseChangesRecovering } from "../core/parser.js";
12
12
  import { buildFailureReport, buildParseErrorReport } from "../core/report.js";
13
13
  import { readClipboard, writeClipboard } from "../infra/clipboard.js";
14
14
  import { loadConfig } from "../infra/config.js";
15
15
  import { findRecentChangesFiles, resolveDownloadsDir, } from "../infra/downloads.js";
16
16
  import { deleteFile, isInsideRoot, readFileState, sha256, writeBytes } from "../infra/files.js";
17
17
  import { gitDirtyFiles } from "../infra/git.js";
18
- import { err, out } from "../infra/term.js";
18
+ import { err, out, outEmphasis } from "../infra/term.js";
19
19
  import { beginHistory, createHistoryId, finishHistory, pruneHistory, } from "../infra/history.js";
20
20
  import { confirm } from "../infra/prompt.js";
21
21
  import { findProjectRoot } from "../infra/root.js";
@@ -86,13 +86,19 @@ export async function applyCommand(argv) {
86
86
  if (!existsSync(join(root, ".petari"))) {
87
87
  out("ヒント: 初回は `petari init` を実行すると protocol.md や設定の雛形が整います");
88
88
  }
89
- // レポートを標準出力へ出し、--clip-report ならクリップボードにもコピーする (§7)
89
+ // レポートを標準出力へ出し、クリップボードにもコピーする (§7)。
90
+ // 既定 (clipReportOnFailure: true) で自動コピーし、config で無効化できる。--clip-report は常に有効
90
91
  const emitReport = async (report) => {
91
92
  out(report);
92
- if (values["clip-report"]) {
93
+ if (values["clip-report"] || config.clipReportOnFailure) {
93
94
  try {
94
95
  await writeClipboard(report);
95
- out("(レポートをクリップボードにコピーしました)");
96
+ // 貼り返しを忘れないよう、コピー済みであることを最後に強調表示する
97
+ const rule = "━".repeat(56);
98
+ outEmphasis(rule);
99
+ outEmphasis(" 失敗レポートをクリップボードにコピーしました ", true);
100
+ outEmphasis(" → そのまま AI チャットに貼り付けて再依頼できます (Ctrl+V)");
101
+ outEmphasis(rule);
96
102
  }
97
103
  catch (e) {
98
104
  err(`petari: クリップボードへのコピーに失敗: ${e instanceof Error ? e.message : String(e)}`);
@@ -166,13 +172,19 @@ export async function applyCommand(argv) {
166
172
  }
167
173
  source = { type: newest.origin, path: newest.path };
168
174
  }
169
- // 1. パース (構文エラーは即時失敗・§4.1)
170
- const { changeSet, issues } = parseChanges(changesText);
175
+ // 1. パース (構文エラーは即時失敗・§4.1)。strict で失敗したら寛容パースを試す (§3.5)
176
+ const { changeSet, issues, repairs } = parseChangesRecovering(changesText);
171
177
  if (issues.length > 0) {
172
178
  err("petari: changes.md の構文エラーです。何も書き込んでいません。\n");
173
179
  await emitReport(buildParseErrorReport(issues));
174
180
  return 1;
175
181
  }
182
+ if (repairs.length > 0) {
183
+ out(`規約からの軽微な逸脱 ${repairs.length} 件を自動補正して解釈しました:`);
184
+ for (const r of repairs)
185
+ out(` ${r.line} 行目: ${r.message}`);
186
+ out("");
187
+ }
176
188
  // 2. 全ブロックのドライラン検証
177
189
  const states = new Map();
178
190
  for (const f of changeSet.files) {
@@ -192,7 +204,7 @@ export async function applyCommand(argv) {
192
204
  // 3. 失敗があれば何も書き込まずレポート (§4.1, §7)
193
205
  if (!plan.ok && !values.partial) {
194
206
  err(`petari: 検証に失敗しました (${plan.failures.length} 件)。何も書き込んでいません。\n`);
195
- await emitReport(buildFailureReport(plan.failures));
207
+ await emitReport(buildFailureReport(plan.failures, { outcomes: plan.outcomes, nothingWritten: true }));
196
208
  return 1;
197
209
  }
198
210
  const applicable = plan.outcomes.filter(isApplicable);
@@ -205,7 +217,7 @@ export async function applyCommand(argv) {
205
217
  return 0;
206
218
  }
207
219
  err("petari: 適用できる変更がありません。\n");
208
- await emitReport(buildFailureReport(plan.failures));
220
+ await emitReport(buildFailureReport(plan.failures, { outcomes: plan.outcomes, nothingWritten: true }));
209
221
  return 1;
210
222
  }
211
223
  if (values["dry-run"]) {
@@ -214,7 +226,7 @@ export async function applyCommand(argv) {
214
226
  printAlreadyApplied(plan.outcomes);
215
227
  if (plan.failures.length > 0) {
216
228
  out("");
217
- await emitReport(buildFailureReport(plan.failures));
229
+ await emitReport(buildFailureReport(plan.failures, { outcomes: plan.outcomes, nothingWritten: true }));
218
230
  }
219
231
  return 0;
220
232
  }
@@ -303,7 +315,8 @@ export async function applyCommand(argv) {
303
315
  if (plan.failures.length > 0) {
304
316
  out(`スキップした失敗 ${plan.failures.length} 件のレポート:`);
305
317
  out("");
306
- await emitReport(buildFailureReport(plan.failures));
318
+ // --partial で成功分は書き込み済みのため nothingWritten は付けない
319
+ await emitReport(buildFailureReport(plan.failures, { outcomes: plan.outcomes }));
307
320
  }
308
321
  // 6. 自動検出 (Downloads / プロジェクト直下) 由来なら changes.md を履歴へ移動
309
322
  // (原本は history に保存済み・§4.1)。全件適用済みで履歴を作らなかった場合は削除しない
@@ -13,7 +13,8 @@ const CONFIG_TEMPLATE = `{
13
13
  "downloadsDir": null,
14
14
  "newFile": { "encoding": "utf8", "eol": "lf" },
15
15
  "historyLimit": null,
16
- "vscodeCommand": "code"
16
+ "vscodeCommand": "code",
17
+ "clipReportOnFailure": true
17
18
  }
18
19
  `;
19
20
  export async function initCommand(argv) {
@@ -21,7 +21,7 @@ import { invalidPathReason } from "../core/applier.js";
21
21
  import { diffLines, toSideBySideRows } from "../core/diff.js";
22
22
  import { buildReportPage } from "../core/diff-html.js";
23
23
  import { EncodingError, decodeFile } from "../core/encoding.js";
24
- import { parseChanges } from "../core/parser.js";
24
+ import { parseChangesRecovering } from "../core/parser.js";
25
25
  import { openInBrowser } from "../infra/browser.js";
26
26
  import { loadConfig } from "../infra/config.js";
27
27
  import { monacoVendorDir, startDiffServer } from "../infra/diff-server.js";
@@ -273,7 +273,8 @@ export async function showCommand(argv) {
273
273
  // --changes: CHANGES セクション (概要・影響一覧・Mermaid) の表示 (§13-4)
274
274
  if (values.changes) {
275
275
  const text = readFileSync(join(hdir, "changes.md"), "utf8");
276
- const { changeSet } = parseChanges(text);
276
+ // 寛容パースで適用した履歴の changes.md も表示できるようにする (§3.5)
277
+ const { changeSet } = parseChangesRecovering(text);
277
278
  out(changeSet.header !== "" ? changeSet.header : "(CHANGES セクションがありません)");
278
279
  return 0;
279
280
  }
@@ -1,5 +1,6 @@
1
1
  import { EncodingError, decodeFile, encodeDocument, findUnencodable, makeDocument, } from "./encoding.js";
2
2
  import { STAGE_LABEL, contentEqualsStage, findContentStage, matchBlock, } from "./matcher.js";
3
+ import { analyzeNearest } from "./nearest.js";
3
4
  /** Windows の予約デバイス名 (拡張子付きも不可) */
4
5
  const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..+)?$/i;
5
6
  /** §9: プロジェクトルート相対のみ。絶対パス・`..`・`~`・Windows 特殊パスを拒否 */
@@ -70,14 +71,32 @@ function planReplace(change, state, fallbackEol) {
70
71
  continue;
71
72
  }
72
73
  }
74
+ if (m.reason === "ambiguous") {
75
+ // 位置は同一ファイル内の先行ブロック適用後の行配列に対する行番号 (通常は実ファイルと一致)
76
+ const positions = (m.positions ?? []).map((p) => `${p + 1}`);
77
+ const shown = positions.length > 5
78
+ ? `${positions.slice(0, 5).join(", ")} 行目 他 ${positions.length - 5} 箇所`
79
+ : `${positions.join(", ")} 行目`;
80
+ failures.push({
81
+ path: change.path,
82
+ kind: "block-ambiguous",
83
+ message: `ブロック ${block.index}: SEARCH が ${m.count} 箇所にマッチしました (${STAGE_LABEL[m.stage]}: ${shown})。前後の行を追加して一意に特定できる範囲にしてください`,
84
+ block,
85
+ });
86
+ continue;
87
+ }
73
88
  const checkedReplace = block.replace.some((l) => l.trim() !== "");
74
- const message = m.reason === "ambiguous"
75
- ? `ブロック ${block.index}: SEARCH が ${m.count} 箇所にマッチしました (${STAGE_LABEL[m.stage]})。一意に特定できる範囲を含めてください`
76
- : `ブロック ${block.index}: SEARCH が現在のファイル内容に見つかりません` +
77
- (checkedReplace
78
- ? " (REPLACE の内容も見つからないため、changes.md の基準スナップショットが現在のコードベースとずれている可能性があります)"
79
- : "");
80
- failures.push({ path: change.path, kind: m.reason === "ambiguous" ? "block-ambiguous" : "block-not-found", message, block });
89
+ const message = `ブロック ${block.index}: SEARCH が現在のファイル内容に見つかりません` +
90
+ (checkedReplace
91
+ ? " (REPLACE の内容も見つからないため、changes.md の基準スナップショットが現在のコードベースとずれている可能性があります)"
92
+ : "");
93
+ failures.push({
94
+ path: change.path,
95
+ kind: "block-not-found",
96
+ message,
97
+ block,
98
+ nearest: analyzeNearest(textLines, block.search, applied.length > 0),
99
+ });
81
100
  continue;
82
101
  }
83
102
  const replacement = m.replacement;
@@ -102,7 +102,7 @@ export function matchBlock(lines, block) {
102
102
  return { ok: true, block, stage, start, end, replacement };
103
103
  }
104
104
  if (found.length > 1) {
105
- return { ok: false, block, reason: "ambiguous", stage, count: found.length };
105
+ return { ok: false, block, reason: "ambiguous", stage, count: found.length, positions: found };
106
106
  }
107
107
  }
108
108
  return { ok: false, block, reason: "not-found" };
@@ -0,0 +1,178 @@
1
+ /**
2
+ * SEARCH 不一致時の近傍診断 (§7 失敗レポートの拡充)。
3
+ * 実ファイル内で SEARCH に最も近い領域を特定し、どの行がどう違うかを構造化して返す。
4
+ * I/O を持たない純粋ロジック。レポートの文字列化は renderNearest が行う。
5
+ *
6
+ * 2026-08-25 の実運用事例 (AI が失敗原因を空白/エンコーディングと誤診し 4 往復) を受けて追加。
7
+ * 抜粋行は verbatim で保持する — AI がそのまま次の SEARCH へコピーする前提のため、
8
+ * 制御文字の可視化などの加工は行わない (差分の説明は note 側に分離する)。
9
+ */
10
+ /** 前後のコンテキストとして抜粋に含める行数 */
11
+ const CONTEXT_LINES = 2;
12
+ /** レポートに載せるウィンドウの最大行数 (巨大 SEARCH の暴走防止) */
13
+ const MAX_RENDER_LINES = 40;
14
+ /** 「存在しない行」一覧の最大表示数 */
15
+ const MAX_MISSING_LINES = 8;
16
+ const collapseWs = (s) => s.replace(/[ \t]+/g, " ");
17
+ /**
18
+ * 目視で区別しづらい文字を通常の文字へ畳み込む。畳み込み後に一致するなら、
19
+ * その行の差は「見えない文字」だけに因る (レポートでコードポイントを示す価値がある)。
20
+ */
21
+ const FOLD_RULES = [
22
+ [/[\u3000\u00A0]/g, " "], // 全角スペース / ノーブレークスペース
23
+ [/[\u200B-\u200D\uFEFF]/g, ""], // ゼロ幅文字
24
+ [/\u301C/g, "\uFF5E"], // 波ダッシュ ↔ 全角チルダ (Shift_JIS デコーダ差の定番)
25
+ ];
26
+ const foldConfusable = (s) => FOLD_RULES.reduce((t, [re, rep]) => t.replace(re, rep), s);
27
+ function verdictOf(fileTrim, searchTrim) {
28
+ if (fileTrim === searchTrim)
29
+ return "match";
30
+ if (collapseWs(fileTrim) === collapseWs(searchTrim))
31
+ return "ws-only";
32
+ return "differ";
33
+ }
34
+ /** 最初に異なるコードポイントの位置と内容 (前後空白は無視して比較) */
35
+ function describeFirstDiff(fileLine, searchLine) {
36
+ const a = [...fileLine.trim()];
37
+ const b = [...searchLine.trim()];
38
+ let i = 0;
39
+ while (i < a.length && i < b.length && a[i] === b[i])
40
+ i++;
41
+ const cp = (arr, idx) => {
42
+ const ch = arr[idx];
43
+ if (ch === undefined)
44
+ return "行末";
45
+ return `U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
46
+ };
47
+ return `${i + 1} 文字目から異なります (実ファイル側: ${cp(a, i)} / SEARCH 側: ${cp(b, i)})`;
48
+ }
49
+ function noteFor(fileLine, searchLine, verdict) {
50
+ if (verdict === "ws-only") {
51
+ return "行内の連続空白の個数のみが異なります (実ファイル側の行をそのままコピーしてください)";
52
+ }
53
+ if (verdict === "differ") {
54
+ const ft = fileLine.trim();
55
+ const st = searchLine.trim();
56
+ if (collapseWs(foldConfusable(ft)) === collapseWs(foldConfusable(st))) {
57
+ return `見た目で区別しづらい文字の差です。${describeFirstDiff(fileLine, searchLine)}`;
58
+ }
59
+ }
60
+ return undefined;
61
+ }
62
+ /**
63
+ * SEARCH に最も近い実ファイル領域を探す。
64
+ * スコア: 前後空白無視の一致 = 3 点、空白圧縮一致 = 2 点、不可視文字の畳み込みまで
65
+ * かけて一致 = 1 点。最高得点の最初の位置を採用する (全比較段が 0 のときのみ null)。
66
+ * 走査が O(ファイル行数 × SEARCH 行数) になるため、正規化はすべて事前計算する。
67
+ */
68
+ export function analyzeNearest(fileLines, search, afterPriorBlocks = false) {
69
+ const fTrim = fileLines.map((l) => l.trim());
70
+ const sTrim = search.map((l) => l.trim());
71
+ const fColl = fTrim.map(collapseWs);
72
+ const sColl = sTrim.map(collapseWs);
73
+ const fFold = fTrim.map((t) => collapseWs(foldConfusable(t)));
74
+ const sFold = sTrim.map((t) => collapseWs(foldConfusable(t)));
75
+ const lineHits = [];
76
+ search.forEach((text, i) => {
77
+ const t = sTrim[i];
78
+ if (t === "")
79
+ return; // 空行はどこにでも一致するため対象外
80
+ let count = 0;
81
+ for (const f of fTrim)
82
+ if (f === t)
83
+ count++;
84
+ lineHits.push({ index: i + 1, text, count });
85
+ });
86
+ const size = Math.min(search.length, fileLines.length);
87
+ let bestStart = -1;
88
+ let bestScore = 0;
89
+ let bestMatch = 0;
90
+ for (let i = 0; size > 0 && i + size <= fileLines.length; i++) {
91
+ let score = 0;
92
+ let matches = 0;
93
+ for (let j = 0; j < size; j++) {
94
+ if (fTrim[i + j] === sTrim[j]) {
95
+ score += 3;
96
+ matches++;
97
+ }
98
+ else if (fColl[i + j] === sColl[j]) {
99
+ score += 2;
100
+ }
101
+ else if (fFold[i + j] === sFold[j]) {
102
+ score += 1;
103
+ }
104
+ }
105
+ if (score > bestScore) {
106
+ bestScore = score;
107
+ bestStart = i;
108
+ bestMatch = matches;
109
+ }
110
+ }
111
+ if (bestStart < 0)
112
+ return { lineHits, window: null, afterPriorBlocks };
113
+ const lines = [];
114
+ const from = Math.max(0, bestStart - CONTEXT_LINES);
115
+ const to = Math.min(fileLines.length, bestStart + size + CONTEXT_LINES);
116
+ for (let i = from; i < to; i++) {
117
+ const inWindow = i >= bestStart && i < bestStart + size;
118
+ if (!inWindow) {
119
+ lines.push({ lineNo: i + 1, text: fileLines[i], verdict: null });
120
+ continue;
121
+ }
122
+ const j = i - bestStart;
123
+ const verdict = verdictOf(fTrim[i], sTrim[j]);
124
+ const note = noteFor(fileLines[i], search[j], verdict);
125
+ lines.push({
126
+ lineNo: i + 1,
127
+ text: fileLines[i],
128
+ verdict,
129
+ ...(note !== undefined ? { note } : {}),
130
+ });
131
+ }
132
+ return {
133
+ lineHits,
134
+ window: { total: search.length, matchCount: bestMatch, lines },
135
+ afterPriorBlocks,
136
+ };
137
+ }
138
+ const MARK = { match: "=", "ws-only": "~", differ: "!" };
139
+ /** 失敗レポートに埋め込む診断テキスト。抜粋行は「│」の右側が実ファイルの verbatim */
140
+ export function renderNearest(a) {
141
+ const out = ["#### 参考: 実ファイルの該当箇所 (petari が現在のファイル内容から自動抽出)"];
142
+ if (a.afterPriorBlocks) {
143
+ out.push("(同一ファイル内の先行ブロックを適用した後の想定内容です。SEARCH はこの内容に一致させてください)");
144
+ }
145
+ out.push("");
146
+ const missing = a.lineHits.filter((h) => h.count === 0);
147
+ if (missing.length > 0) {
148
+ out.push("SEARCH のうち実ファイルに存在しない行 (前後の空白は無視して照合):");
149
+ for (const h of missing.slice(0, MAX_MISSING_LINES)) {
150
+ out.push(`- SEARCH ${h.index} 行目: ${h.text.trim()}`);
151
+ }
152
+ if (missing.length > MAX_MISSING_LINES) {
153
+ out.push(`- …他 ${missing.length - MAX_MISSING_LINES} 行`);
154
+ }
155
+ }
156
+ else if (a.window !== null) {
157
+ out.push("SEARCH の各行は実ファイル内に個別には存在しますが、この並びでは連続していません。");
158
+ }
159
+ if (a.window === null) {
160
+ out.push("", "SEARCH のどの行も (前後の空白を無視しても) 現在のファイルに見つかりません。", "changes.md の基準にしたスナップショット (repomix 等) が古いか、チャット側の要約・", "検索処理により実在しないコードを参照している可能性があります。推測で SEARCH を", "書き換えず、最新のファイル内容の共有を受けてから作り直してください。");
161
+ return out;
162
+ }
163
+ const w = a.window;
164
+ out.push("", `最も近い領域 (SEARCH ${w.total} 行中 ${w.matchCount} 行が一致):`, "```");
165
+ for (const line of w.lines.slice(0, MAX_RENDER_LINES)) {
166
+ const mark = line.verdict === null ? " " : MARK[line.verdict];
167
+ out.push(`${mark} ${String(line.lineNo).padStart(5)}│${line.text}`);
168
+ }
169
+ if (w.lines.length > MAX_RENDER_LINES) {
170
+ out.push(`… (以下 ${w.lines.length - MAX_RENDER_LINES} 行省略)`);
171
+ }
172
+ out.push("```", "凡例: ! = SEARCH と内容が異なる行 / ~ = 空白の個数のみ異なる行 / = = 一致した行 / 無印 = 前後の文脈");
173
+ for (const line of w.lines) {
174
+ if (line.note !== undefined)
175
+ out.push(`- ${line.lineNo} 行目: ${line.note}`);
176
+ }
177
+ return out;
178
+ }
@@ -6,6 +6,28 @@ const CONTENT_END = ">>>>>>> END";
6
6
  const CHANGES_HEADING = "## CHANGES";
7
7
  const FILE_PREFIX = "### FILE:";
8
8
  const FILE_RE = /^### FILE:\s*(.+?)\s*\((replace|create|rewrite|delete)\)$/;
9
+ const EXACT_MARKER = {
10
+ search: SEARCH,
11
+ divider: DIVIDER,
12
+ "replace-end": REPLACE_END,
13
+ content: CONTENT,
14
+ "content-end": CONTENT_END,
15
+ };
16
+ // 寛容モード (§3.5) の許容パターン。strict パースが失敗したときだけ試す「惜しい」逸脱で、
17
+ // AI チャットの実出力で観測された崩れ (記号の個数ずれ・スペース欠落・見出しレベルずれ・
18
+ // 全体のコードフェンス包み) を対象にする。誤検知しても lenient 結果は issues が 1 件でも
19
+ // あれば破棄して strict のエラーに戻すため、静かな誤解釈は採用されない。
20
+ const LENIENT_MARKER = {
21
+ search: /^<{5,16}\s*SEARCH$/,
22
+ divider: /^={6,10}$/,
23
+ "replace-end": /^>{5,16}\s*REPLACE$/,
24
+ content: /^<{5,16}\s*CONTENT$/,
25
+ "content-end": /^>{5,16}\s*END$/,
26
+ };
27
+ const LENIENT_CHANGES_RE = /^#{1,6}\s*CHANGES$/;
28
+ const LENIENT_FILE_ATTEMPT_RE = /^#{1,6}\s*FILE\s*:/;
29
+ const LENIENT_FILE_RE = /^#{1,6}\s*FILE\s*:\s*(.+?)\s*\((replace|create|rewrite|delete)\)$/;
30
+ const FENCE_RE = /^`{3,}[A-Za-z0-9_-]*$/;
9
31
  /**
10
32
  * changes.md をパースする (§3)。
11
33
  *
@@ -15,6 +37,27 @@ const FILE_RE = /^### FILE:\s*(.+?)\s*\((replace|create|rewrite|delete)\)$/;
15
37
  * - 構文の問題は issues に集約する。issues が空でなければ適用してはならない。
16
38
  */
17
39
  export function parseChanges(text) {
40
+ return parseCore(text, null);
41
+ }
42
+ /**
43
+ * 寛容パース (§3.5)。strict で成功すればそのまま返し、失敗した場合のみ lenient で
44
+ * 再パースする。lenient 結果は issues がゼロかつ補正が 1 件以上のときだけ採用し、
45
+ * それ以外は strict の issues を返す (エラーメッセージの分かりやすさを優先)。
46
+ * ブロック内部 (SEARCH/REPLACE/CONTENT の本文) ではフェンス行を除去しないため、
47
+ * Markdown ファイル自体を変更対象にしても本文は壊れない。
48
+ */
49
+ export function parseChangesRecovering(text) {
50
+ const strict = parseCore(text, null);
51
+ if (strict.issues.length === 0)
52
+ return { ...strict, repairs: [] };
53
+ const repairs = [];
54
+ const lenient = parseCore(text, repairs);
55
+ if (lenient.issues.length === 0 && repairs.length > 0)
56
+ return { ...lenient, repairs };
57
+ return { ...strict, repairs: [] };
58
+ }
59
+ /** repairs が null なら strict、配列なら lenient (補正内容を追記していく) */
60
+ function parseCore(text, repairs) {
18
61
  const rawLines = text.split(/\r?\n/);
19
62
  const issues = [];
20
63
  const files = [];
@@ -26,6 +69,19 @@ export function parseChanges(text) {
26
69
  let replaceBuf = [];
27
70
  let contentBuf = [];
28
71
  let blockLine = 0;
72
+ // 該当行が指定マーカーか。lenient では許容パターンも試し、一致したら補正として記録する。
73
+ // 呼び出し側は true を返した分岐で必ずそのマーカーとして処理するため、記録と動作は一致する
74
+ const isMarker = (line, no, kind) => {
75
+ if (line === EXACT_MARKER[kind])
76
+ return true;
77
+ if (repairs === null || !LENIENT_MARKER[kind].test(line))
78
+ return false;
79
+ repairs.push({
80
+ line: no,
81
+ message: `マーカー "${line}" を "${EXACT_MARKER[kind]}" として解釈しました`,
82
+ });
83
+ return true;
84
+ };
29
85
  // クロージャ経由の代入は TS の narrowing が追えないため、cur へのアクセスはここを通す
30
86
  function requireCur() {
31
87
  if (cur === null)
@@ -76,13 +132,28 @@ export function parseChanges(text) {
76
132
  const line = raw.trimEnd();
77
133
  const no = idx + 1;
78
134
  if (state === "preamble") {
79
- if (line === CHANGES_HEADING)
135
+ if (line === CHANGES_HEADING) {
136
+ state = "header";
137
+ }
138
+ else if (repairs !== null && LENIENT_CHANGES_RE.test(line)) {
139
+ repairs.push({ line: no, message: `見出し "${line}" を "${CHANGES_HEADING}" として解釈しました` });
80
140
  state = "header";
141
+ }
81
142
  continue;
82
143
  }
83
144
  if (state === "header" || state === "fileTop") {
84
- if (line.startsWith(FILE_PREFIX)) {
85
- const m = FILE_RE.exec(line);
145
+ const fileAttempt = line.startsWith(FILE_PREFIX) || (repairs !== null && LENIENT_FILE_ATTEMPT_RE.test(line));
146
+ if (fileAttempt) {
147
+ let m = FILE_RE.exec(line);
148
+ if (m === null && repairs !== null) {
149
+ m = LENIENT_FILE_RE.exec(line);
150
+ if (m !== null) {
151
+ repairs.push({
152
+ line: no,
153
+ message: `FILE 行 "${line}" を "### FILE: ${m[1]} (${m[2]})" として解釈しました`,
154
+ });
155
+ }
156
+ }
86
157
  if (m !== null) {
87
158
  startSection(m[1], m[2], no);
88
159
  }
@@ -95,7 +166,10 @@ export function parseChanges(text) {
95
166
  continue;
96
167
  }
97
168
  if (state === "header") {
98
- if (line === SEARCH || line === CONTENT || line === REPLACE_END || line === CONTENT_END) {
169
+ if (isMarker(line, no, "search") ||
170
+ isMarker(line, no, "content") ||
171
+ isMarker(line, no, "replace-end") ||
172
+ isMarker(line, no, "content-end")) {
99
173
  issues.push({
100
174
  line: no,
101
175
  message: `${line} が最初の FILE 行より前に現れました (### FILE: 行が壊れている可能性があります)`,
@@ -109,9 +183,13 @@ export function parseChanges(text) {
109
183
  // fileTop: FILE セクション内・ブロック外
110
184
  if (line === "")
111
185
  continue;
186
+ if (repairs !== null && FENCE_RE.test(line)) {
187
+ repairs.push({ line: no, message: `コードフェンス行 "${line}" を無視しました` });
188
+ continue;
189
+ }
112
190
  const section = requireCur();
113
191
  if (section.op === "replace") {
114
- if (line === SEARCH) {
192
+ if (isMarker(line, no, "search")) {
115
193
  state = "search";
116
194
  searchBuf = [];
117
195
  replaceBuf = [];
@@ -125,7 +203,7 @@ export function parseChanges(text) {
125
203
  }
126
204
  }
127
205
  else if (section.op === "create" || section.op === "rewrite") {
128
- if (line === CONTENT) {
206
+ if (isMarker(line, no, "content")) {
129
207
  if (section.content !== null) {
130
208
  issues.push({
131
209
  line: no,
@@ -153,17 +231,17 @@ export function parseChanges(text) {
153
231
  continue;
154
232
  }
155
233
  if (state === "search") {
156
- if (line === DIVIDER) {
234
+ if (isMarker(line, no, "divider")) {
157
235
  state = "replace";
158
236
  }
159
- else if (line === REPLACE_END) {
237
+ else if (isMarker(line, no, "replace-end")) {
160
238
  issues.push({
161
239
  line: no,
162
240
  message: `${requireCur().path}: ======= (区切り) がないまま >>>>>>> REPLACE が現れました`,
163
241
  });
164
242
  state = "fileTop";
165
243
  }
166
- else if (line === SEARCH) {
244
+ else if (isMarker(line, no, "search")) {
167
245
  issues.push({
168
246
  line: no,
169
247
  message: `${requireCur().path}: SEARCH ブロックが閉じられないまま次の <<<<<<< SEARCH が現れました`,
@@ -178,7 +256,7 @@ export function parseChanges(text) {
178
256
  }
179
257
  if (state === "replace") {
180
258
  const section = requireCur();
181
- if (line === REPLACE_END) {
259
+ if (isMarker(line, no, "replace-end")) {
182
260
  if (searchBuf.length === 0) {
183
261
  issues.push({ line: blockLine, message: `${section.path}: SEARCH ブロックが空です` });
184
262
  }
@@ -190,14 +268,14 @@ export function parseChanges(text) {
190
268
  });
191
269
  state = "fileTop";
192
270
  }
193
- else if (line === DIVIDER) {
271
+ else if (isMarker(line, no, "divider")) {
194
272
  issues.push({
195
273
  line: no,
196
274
  message: `${section.path}: ======= (区切り) が 1 ブロック内に複数あります`,
197
275
  });
198
276
  state = "fileTop";
199
277
  }
200
- else if (line === SEARCH) {
278
+ else if (isMarker(line, no, "search")) {
201
279
  issues.push({
202
280
  line: no,
203
281
  message: `${section.path}: >>>>>>> REPLACE で閉じられないまま次の <<<<<<< SEARCH が現れました`,
@@ -213,7 +291,7 @@ export function parseChanges(text) {
213
291
  continue;
214
292
  }
215
293
  // state === "content"
216
- if (line === CONTENT_END) {
294
+ if (isMarker(line, no, "content-end")) {
217
295
  requireCur().content = contentBuf;
218
296
  state = "fileTop";
219
297
  }
@@ -1,26 +1,94 @@
1
+ import { STAGE_LABEL } from "./matcher.js";
2
+ import { renderNearest } from "./nearest.js";
3
+ const MATCH_SPEC_NOTE = [
4
+ "## petari の照合仕様 (SEARCH を修正する前に必ず読んでください)",
5
+ "",
6
+ "- petari は行末空白・インデントの深さ (タブ/スペース混在含む)・改行コード・文字コード",
7
+ " (UTF-8 / Shift_JIS) の違いを自動で吸収して照合しています",
8
+ "- したがって「SEARCH が見つかりません」は空白・インデント・エンコーディングの問題では",
9
+ " ありません。行の文字内容そのものが現在のファイルと異なっています",
10
+ "- 空白の調整・ASCII 行だけへの縮小・別アンカーへの乗り換えでは解決しません。各失敗に",
11
+ " 添付した「実ファイルの該当箇所」の抜粋から、行をそのままコピーしてください",
12
+ ];
1
13
  const RE_REQUEST = `## 依頼
2
14
 
3
15
  上記の失敗した各ブロックについて、SEARCH 部分を現在のファイル内容と完全に一致するよう修正し、
4
16
  changes.md 全体を元の規約フォーマット (## CHANGES から始まる形式) で再出力してください。
17
+ - SEARCH の修正には「実ファイルの該当箇所」の抜粋を使い、「│」より右側を一字一句そのまま
18
+ コピーしてください (行頭の「! ~ =」の記号と行番号は含めません)
5
19
  - SEARCH ブロックにはファイル内で一意に特定できる範囲を含めてください
6
- - 失敗していないファイル・ブロックも含めた完全な changes.md を出力してください`;
20
+ - 失敗していないファイル・ブロックも含めた完全な changes.md を出力してください
21
+ - 抜粋にも SEARCH に相当する行が見当たらない場合は、推測で書き換えず、その旨を報告して
22
+ 最新のファイル内容の共有を依頼してください`;
23
+ /** 検証結果の一覧 (成功したブロックも含めて全て)。ファイル間の対比が診断材料になる */
24
+ function outcomeSummary(outcomes) {
25
+ const lines = ["## 検証結果の一覧 (全ファイル・全ブロック)", ""];
26
+ for (const o of outcomes) {
27
+ const c = o.change;
28
+ if (c.op !== "replace") {
29
+ const status = o.failures.length > 0
30
+ ? `NG (${o.failures[0]?.message})`
31
+ : o.alreadyApplied
32
+ ? "OK (適用済みのためスキップ)"
33
+ : "OK (検証通過)";
34
+ lines.push(`- ${c.path} (${c.op}): ${status}`);
35
+ continue;
36
+ }
37
+ const fileLevel = o.failures.find((f) => f.block === undefined);
38
+ if (fileLevel !== undefined) {
39
+ lines.push(`- ${c.path} (replace): NG (${fileLevel.message})`);
40
+ continue;
41
+ }
42
+ const okCount = o.appliedBlocks.length + o.alreadyAppliedBlocks.length;
43
+ lines.push(`- ${c.path} (replace): ${okCount}/${o.totalBlocks} ブロック一致`);
44
+ const byIndex = new Map();
45
+ for (const b of o.appliedBlocks) {
46
+ byIndex.set(b.block.index, `OK 一致 (${STAGE_LABEL[b.stage]})`);
47
+ }
48
+ for (const b of o.alreadyAppliedBlocks) {
49
+ byIndex.set(b.block.index, "OK 適用済み (REPLACE が既に存在)");
50
+ }
51
+ for (const f of o.failures) {
52
+ if (f.block === undefined)
53
+ continue;
54
+ const label = f.kind === "block-ambiguous"
55
+ ? "NG 複数箇所に一致 (一意でない)"
56
+ : f.kind === "unencodable"
57
+ ? "NG 変換できない文字を含む"
58
+ : "NG SEARCH 不一致 (詳細は下記)";
59
+ byIndex.set(f.block.index, label);
60
+ }
61
+ for (const [index, status] of [...byIndex.entries()].sort((a, b) => a[0] - b[0])) {
62
+ lines.push(` - ブロック ${index}: ${status}`);
63
+ }
64
+ }
65
+ return lines;
66
+ }
7
67
  /** 検証失敗 (マッチング・パス・エンコーディング) のレポート */
8
- export function buildFailureReport(failures) {
9
- const parts = [
10
- "以下の変更ブロックが現在のコードベースに適用できませんでした。",
11
- "",
12
- "## 適用失敗の詳細",
13
- ];
68
+ export function buildFailureReport(failures, ctx = {}) {
69
+ const parts = ["以下の変更ブロックが現在のコードベースに適用できませんでした。"];
70
+ if (ctx.nothingWritten === true) {
71
+ parts.push("", "※ この失敗により petari は何も書き込んでいません (all-or-nothing)。下の一覧で「一致」と", "表示されたブロックもまだファイルには適用されていないため、再出力には失敗していない", "ブロックもすべて含めてください。");
72
+ }
73
+ parts.push("", ...MATCH_SPEC_NOTE);
74
+ if (ctx.outcomes !== undefined) {
75
+ parts.push("", ...outcomeSummary(ctx.outcomes));
76
+ }
77
+ parts.push("", "## 適用失敗の詳細");
14
78
  for (const f of failures) {
15
79
  parts.push("", `### ${f.path}`, `失敗理由: ${f.message}`);
16
80
  if (f.block !== undefined) {
17
81
  parts.push("", "```", "<<<<<<< SEARCH", ...f.block.search, "=======", ...f.block.replace, ">>>>>>> REPLACE", "```");
18
82
  }
83
+ if (f.nearest !== undefined) {
84
+ parts.push("", ...renderNearest(f.nearest));
85
+ }
19
86
  }
20
87
  parts.push("", RE_REQUEST, "");
21
88
  return parts.join("\n");
22
89
  }
23
- /** 構文エラー (パース失敗) のレポート */
90
+ /** 構文エラー (パース失敗) のレポート。規約文がチャット側で失われていても
91
+ * このレポート単体で再出力を依頼できるよう、フォーマットの要点を再掲する */
24
92
  export function buildParseErrorReport(issues) {
25
93
  const parts = [
26
94
  "受け取った changes.md が規約フォーマットとして解釈できませんでした。",
@@ -29,11 +97,22 @@ export function buildParseErrorReport(issues) {
29
97
  "",
30
98
  ...issues.map((i) => `- ${i.line} 行目: ${i.message}`),
31
99
  "",
100
+ "## 規約フォーマットの要点 (再掲)",
101
+ "",
102
+ "- 出力は必ず行頭の「## CHANGES」の行から始め、変更概要のあとに各ファイルのセクションを置く",
103
+ "- 各ファイルは「### FILE: 相対パス (replace|create|rewrite|delete)」の見出し行で始める",
104
+ "- replace は <<<<<<< SEARCH / ======= / >>>>>>> REPLACE のブロックで書き、",
105
+ " SEARCH の内容は現在のファイルから一字一句そのままコピーする",
106
+ "- create / rewrite は <<<<<<< CONTENT / >>>>>>> END のブロックにファイル全文を書く。delete は本文なし",
107
+ "- マーカー行は必ず行頭から書き、前後に他の文字を付けない (< > = はいずれも 7 個)",
108
+ "- 出力全体や各ブロックをコードフェンス (```) で包まない",
109
+ "- FILE セクションの間に説明文を書かない (説明は冒頭の CHANGES セクションへ)",
110
+ "",
32
111
  "## 依頼",
33
112
  "",
34
113
  "上記の構文エラーを修正し、changes.md 全体を規約フォーマット (## CHANGES から始まり、",
35
114
  "### FILE: 行と <<<<<<< SEARCH / ======= / >>>>>>> REPLACE 等のマーカーを行頭に置く形式) で",
36
- "再出力してください。",
115
+ "再出力してください。失敗していないファイル・ブロックも含めた完全な changes.md を出力してください。",
37
116
  "",
38
117
  ];
39
118
  return parts.join("\n");
@@ -6,7 +6,7 @@
6
6
  * Linux: xclip (インストールされている場合のみ)
7
7
  */
8
8
  import { execFile, spawn } from "node:child_process";
9
- import { mkdtempSync, writeFileSync } from "node:fs";
9
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { promisify } from "node:util";
@@ -48,14 +48,21 @@ export async function writeClipboard(text) {
48
48
  if (process.platform === "win32") {
49
49
  // コンソールのコードページに依存しないよう UTF-8 の一時ファイル経由で渡す。
50
50
  // パスは PowerShell の単一引用符で渡す (' は '' にエスケープ。展開・注入が起きない)
51
- const file = join(mkdtempSync(join(tmpdir(), "petari-clip-")), "report.txt");
52
- writeFileSync(file, text, "utf8");
53
- const quoted = `'${file.replaceAll("'", "''")}'`;
54
- await execFileP("powershell", [
55
- "-NoProfile",
56
- "-Command",
57
- `Get-Content -Raw -Encoding UTF8 -LiteralPath ${quoted} | Set-Clipboard`,
58
- ]);
51
+ const dir = mkdtempSync(join(tmpdir(), "petari-clip-"));
52
+ const file = join(dir, "report.txt");
53
+ try {
54
+ writeFileSync(file, text, "utf8");
55
+ const quoted = `'${file.replaceAll("'", "''")}'`;
56
+ await execFileP("powershell", [
57
+ "-NoProfile",
58
+ "-Command",
59
+ `Get-Content -Raw -Encoding UTF8 -LiteralPath ${quoted} | Set-Clipboard`,
60
+ ]);
61
+ }
62
+ finally {
63
+ // レポートはコードの断片を含むため一時ファイルを残さない
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
59
66
  return;
60
67
  }
61
68
  await viaStdin("xclip", ["-selection", "clipboard"], text);
@@ -6,6 +6,7 @@ export const DEFAULT_CONFIG = {
6
6
  newFile: { encoding: "utf8", eol: "lf" },
7
7
  historyLimit: null,
8
8
  vscodeCommand: "code",
9
+ clipReportOnFailure: true,
9
10
  };
10
11
  /** グローバル設定のパス (§10)。Windows は %APPDATA%、他は XDG (~/.config) */
11
12
  export function globalConfigPath() {
@@ -45,6 +46,9 @@ function validateConfig(c) {
45
46
  if (c.downloadsDir !== null && typeof c.downloadsDir !== "string") {
46
47
  throw new Error("config の downloadsDir が不正です (null または文字列を指定)");
47
48
  }
49
+ if (typeof c.clipReportOnFailure !== "boolean") {
50
+ throw new Error("config の clipReportOnFailure が不正です (true | false)");
51
+ }
48
52
  return c;
49
53
  }
50
54
  /** 既定 < グローバル < プロジェクトの順でマージする (プロジェクト優先・§10) */
@@ -12,3 +12,17 @@ export function sanitizeForTerminal(s) {
12
12
  }
13
13
  export const out = (s) => void process.stdout.write(sanitizeForTerminal(s) + "\n");
14
14
  export const err = (s) => void process.stderr.write(sanitizeForTerminal(s) + "\n");
15
+ const RESET = "\u001B[0m";
16
+ const BOLD_YELLOW = "\u001B[1;33m";
17
+ const BOLD_REVERSE_YELLOW = "\u001B[1;7;33m";
18
+ /**
19
+ * 目立たせたい行の出力。装飾コードはこのファイルのリテラル定数のみで、
20
+ * 本文はサニタイズ後に着色するため ANSI 注入対策 (上記) は保たれる。
21
+ * 非 TTY (パイプ・リダイレクト) と NO_COLOR 指定時は装飾なしで出力する。
22
+ */
23
+ export const outEmphasis = (s, reverse = false) => {
24
+ const clean = sanitizeForTerminal(s);
25
+ const decorate = process.stdout.isTTY === true && process.env["NO_COLOR"] === undefined;
26
+ const colored = decorate ? `${reverse ? BOLD_REVERSE_YELLOW : BOLD_YELLOW}${clean}${RESET}` : clean;
27
+ process.stdout.write(colored + "\n");
28
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "petari",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "petari — paste AI chat patches onto your codebase",
5
5
  "keywords": [
6
6
  "cli",