petari 0.5.0 → 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 +13 -0
- package/dist/commands/apply.js +5 -4
- package/dist/core/applier.js +26 -7
- package/dist/core/matcher.js +1 -1
- package/dist/core/nearest.js +178 -0
- package/dist/core/report.js +74 -7
- package/package.json +1 -1
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
|
| コマンド | 説明 |
|
package/dist/commands/apply.js
CHANGED
|
@@ -204,7 +204,7 @@ export async function applyCommand(argv) {
|
|
|
204
204
|
// 3. 失敗があれば何も書き込まずレポート (§4.1, §7)
|
|
205
205
|
if (!plan.ok && !values.partial) {
|
|
206
206
|
err(`petari: 検証に失敗しました (${plan.failures.length} 件)。何も書き込んでいません。\n`);
|
|
207
|
-
await emitReport(buildFailureReport(plan.failures));
|
|
207
|
+
await emitReport(buildFailureReport(plan.failures, { outcomes: plan.outcomes, nothingWritten: true }));
|
|
208
208
|
return 1;
|
|
209
209
|
}
|
|
210
210
|
const applicable = plan.outcomes.filter(isApplicable);
|
|
@@ -217,7 +217,7 @@ export async function applyCommand(argv) {
|
|
|
217
217
|
return 0;
|
|
218
218
|
}
|
|
219
219
|
err("petari: 適用できる変更がありません。\n");
|
|
220
|
-
await emitReport(buildFailureReport(plan.failures));
|
|
220
|
+
await emitReport(buildFailureReport(plan.failures, { outcomes: plan.outcomes, nothingWritten: true }));
|
|
221
221
|
return 1;
|
|
222
222
|
}
|
|
223
223
|
if (values["dry-run"]) {
|
|
@@ -226,7 +226,7 @@ export async function applyCommand(argv) {
|
|
|
226
226
|
printAlreadyApplied(plan.outcomes);
|
|
227
227
|
if (plan.failures.length > 0) {
|
|
228
228
|
out("");
|
|
229
|
-
await emitReport(buildFailureReport(plan.failures));
|
|
229
|
+
await emitReport(buildFailureReport(plan.failures, { outcomes: plan.outcomes, nothingWritten: true }));
|
|
230
230
|
}
|
|
231
231
|
return 0;
|
|
232
232
|
}
|
|
@@ -315,7 +315,8 @@ export async function applyCommand(argv) {
|
|
|
315
315
|
if (plan.failures.length > 0) {
|
|
316
316
|
out(`スキップした失敗 ${plan.failures.length} 件のレポート:`);
|
|
317
317
|
out("");
|
|
318
|
-
|
|
318
|
+
// --partial で成功分は書き込み済みのため nothingWritten は付けない
|
|
319
|
+
await emitReport(buildFailureReport(plan.failures, { outcomes: plan.outcomes }));
|
|
319
320
|
}
|
|
320
321
|
// 6. 自動検出 (Downloads / プロジェクト直下) 由来なら changes.md を履歴へ移動
|
|
321
322
|
// (原本は history に保存済み・§4.1)。全件適用済みで履歴を作らなかった場合は削除しない
|
package/dist/core/applier.js
CHANGED
|
@@ -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 =
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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;
|
package/dist/core/matcher.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/report.js
CHANGED
|
@@ -1,21 +1,88 @@
|
|
|
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");
|