petari 0.6.0 → 0.8.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 +2 -1
- package/dist/commands/apply.js +7 -1
- package/dist/commands/init.js +25 -8
- package/dist/core/applier.js +12 -1
- package/dist/core/matcher.js +45 -1
- package/dist/core/nearest.js +247 -58
- package/dist/core/report.js +62 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,7 +59,8 @@ slnmix の出力) が実ファイルとずれている**可能性が高いです
|
|
|
59
59
|
長い添付を要約・検索処理で部分的にしか読まないことがあり、AI が見えなかった箇所を
|
|
60
60
|
もっともらしく補って出力することがあります。SEARCH の空白調整や言い換えを繰り返す
|
|
61
61
|
より、スナップショットを取り直して渡し直すのが確実です (petari 側は空白・インデント・
|
|
62
|
-
|
|
62
|
+
空行の位置や数・文字コードの差を吸収して照合するため、それらが原因で失敗することは
|
|
63
|
+
ありません)。
|
|
63
64
|
|
|
64
65
|
### コマンド
|
|
65
66
|
|
package/dist/commands/apply.js
CHANGED
|
@@ -9,7 +9,7 @@ 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
11
|
import { parseChangesRecovering } from "../core/parser.js";
|
|
12
|
-
import { buildFailureReport, buildParseErrorReport } from "../core/report.js";
|
|
12
|
+
import { buildBlankInsensitiveNote, 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";
|
|
@@ -224,6 +224,9 @@ export async function applyCommand(argv) {
|
|
|
224
224
|
out(`dry-run: 適用予定 ${applicable.length} ファイル (書き込みなし)`);
|
|
225
225
|
printPreview(applicable);
|
|
226
226
|
printAlreadyApplied(plan.outcomes);
|
|
227
|
+
const dryRunNote = buildBlankInsensitiveNote(plan.outcomes);
|
|
228
|
+
if (dryRunNote !== null)
|
|
229
|
+
out(dryRunNote);
|
|
227
230
|
if (plan.failures.length > 0) {
|
|
228
231
|
out("");
|
|
229
232
|
await emitReport(buildFailureReport(plan.failures, { outcomes: plan.outcomes, nothingWritten: true }));
|
|
@@ -312,6 +315,9 @@ export async function applyCommand(argv) {
|
|
|
312
315
|
for (const o of applicable)
|
|
313
316
|
out(` ${opLabel(o)}`);
|
|
314
317
|
printAlreadyApplied(plan.outcomes);
|
|
318
|
+
const appliedNote = buildBlankInsensitiveNote(plan.outcomes);
|
|
319
|
+
if (appliedNote !== null)
|
|
320
|
+
out(appliedNote);
|
|
315
321
|
if (plan.failures.length > 0) {
|
|
316
322
|
out(`スキップした失敗 ${plan.failures.length} 件のレポート:`);
|
|
317
323
|
out("");
|
package/dist/commands/init.js
CHANGED
|
@@ -63,15 +63,32 @@ export async function initCommand(argv) {
|
|
|
63
63
|
}
|
|
64
64
|
catch {
|
|
65
65
|
err(`3. ${repomixPath} を JSON として読めないためスキップします`);
|
|
66
|
-
parsed =
|
|
66
|
+
parsed = null;
|
|
67
67
|
}
|
|
68
|
-
if (parsed
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
68
|
+
if (parsed !== null) {
|
|
69
|
+
let changed = false;
|
|
70
|
+
if (parsed.output?.instructionFilePath !== undefined) {
|
|
71
|
+
out(`3. repomix.config.json は instructionFilePath 設定済みです (変更しません)`);
|
|
72
|
+
}
|
|
73
|
+
else if (await ask('3. repomix.config.json に output.instructionFilePath: "protocol.md" を追記しますか?')) {
|
|
74
|
+
parsed.output = { ...(parsed.output ?? {}), instructionFilePath: "protocol.md" };
|
|
75
|
+
changed = true;
|
|
76
|
+
}
|
|
77
|
+
// 空行を落としたスナップショットは SEARCH 不一致 (空行欠落) の定番原因 (2026-08-25 実運用事例)
|
|
78
|
+
const output = parsed.output;
|
|
79
|
+
if (output !== undefined && output.removeEmptyLines === true) {
|
|
80
|
+
if (await ask("3. repomix.config.json の output.removeEmptyLines: true は、スナップショットと実ファイルの空行がずれて SEARCH 不一致の原因になります。false に変更しますか?")) {
|
|
81
|
+
output.removeEmptyLines = false;
|
|
82
|
+
changed = true;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
err(" removeEmptyLines: true のままでは AI の SEARCH から空行が欠けやすくなります (petari は失敗レポートの + 印で検出します)");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (changed) {
|
|
89
|
+
writeFileSync(repomixPath, JSON.stringify(parsed, null, 2) + "\n", "utf8");
|
|
90
|
+
out(` 更新しました: ${repomixPath}`);
|
|
91
|
+
}
|
|
75
92
|
}
|
|
76
93
|
}
|
|
77
94
|
else if (await ask("3. repomix 連携用の repomix.config.json (最小構成) を作成しますか?")) {
|
package/dist/core/applier.js
CHANGED
|
@@ -90,12 +90,23 @@ function planReplace(change, state, fallbackEol) {
|
|
|
90
90
|
(checkedReplace
|
|
91
91
|
? " (REPLACE の内容も見つからないため、changes.md の基準スナップショットが現在のコードベースとずれている可能性があります)"
|
|
92
92
|
: "");
|
|
93
|
+
const nearest = analyzeNearest(textLines, block.search, applied.length > 0);
|
|
94
|
+
// SEARCH が全滅のときのみ REPLACE 側で近傍を推定する (本来当てたかった箇所のヒント)。
|
|
95
|
+
// fold 段のみの偶然一致をノイズとして弾くため matchCount >= 1 を要求する
|
|
96
|
+
let nearestReplace;
|
|
97
|
+
if (nearest.window === null && checkedReplace) {
|
|
98
|
+
const byReplace = analyzeNearest(textLines, block.replace, applied.length > 0, "REPLACE");
|
|
99
|
+
if (byReplace.window !== null && byReplace.window.matchCount >= 1) {
|
|
100
|
+
nearestReplace = byReplace;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
93
103
|
failures.push({
|
|
94
104
|
path: change.path,
|
|
95
105
|
kind: "block-not-found",
|
|
96
106
|
message,
|
|
97
107
|
block,
|
|
98
|
-
nearest
|
|
108
|
+
nearest,
|
|
109
|
+
...(nearestReplace !== undefined ? { nearestReplace } : {}),
|
|
99
110
|
});
|
|
100
111
|
continue;
|
|
101
112
|
}
|
package/dist/core/matcher.js
CHANGED
|
@@ -2,6 +2,7 @@ export const STAGE_LABEL = {
|
|
|
2
2
|
"exact": "完全一致",
|
|
3
3
|
"trim-end": "行末空白無視",
|
|
4
4
|
"trim-all": "インデント無視",
|
|
5
|
+
"blank-insensitive": "空行差無視",
|
|
5
6
|
};
|
|
6
7
|
const STAGES = [
|
|
7
8
|
{ stage: "exact", eq: (a, b) => a === b },
|
|
@@ -89,7 +90,47 @@ export function reindent(replaceLines, searchBase, fileBase) {
|
|
|
89
90
|
return fileBase + line.trimStart();
|
|
90
91
|
});
|
|
91
92
|
}
|
|
92
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* 4 段目 blank-insensitive (§6): SEARCH と実ファイルの双方から空行を除いた行列を
|
|
95
|
+
* trim-all 比較 (a.trim() === b.trim()) で連続一致探索する。マッチ範囲は最初と最後の
|
|
96
|
+
* 非空行で囲まれた元ファイル範囲 (内部の空行は含む、外側の空行は含まない)。
|
|
97
|
+
* SEARCH の先頭・末尾の空行は照合範囲に含めず、実ファイル側の外側の空行はそのまま残る。
|
|
98
|
+
* REPLACE 側の空行はそのまま挿入する (意図的な空行挿入を潰さないため、勝手に削らない)。
|
|
99
|
+
* 見つからないときは null (呼び出し元が not-found にする)。
|
|
100
|
+
*/
|
|
101
|
+
function matchBlankInsensitive(lines, block) {
|
|
102
|
+
const searchNonBlank = block.search.filter((l) => l.trim() !== "");
|
|
103
|
+
// SEARCH が空行のみなら試行しない (どこにでも一致し得るため)
|
|
104
|
+
if (searchNonBlank.length === 0)
|
|
105
|
+
return null;
|
|
106
|
+
const fileNonBlank = [];
|
|
107
|
+
const origIdx = [];
|
|
108
|
+
lines.forEach((line, i) => {
|
|
109
|
+
if (line.trim() !== "") {
|
|
110
|
+
fileNonBlank.push(line);
|
|
111
|
+
origIdx.push(i);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
const found = findMatches(fileNonBlank, searchNonBlank, (a, b) => a.trim() === b.trim());
|
|
115
|
+
if (found.length === 0)
|
|
116
|
+
return null;
|
|
117
|
+
if (found.length > 1) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
block,
|
|
121
|
+
reason: "ambiguous",
|
|
122
|
+
stage: "blank-insensitive",
|
|
123
|
+
count: found.length,
|
|
124
|
+
positions: found.map((h) => origIdx[h]),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const hit = found[0];
|
|
128
|
+
const start = origIdx[hit];
|
|
129
|
+
const end = origIdx[hit + searchNonBlank.length - 1] + 1;
|
|
130
|
+
const replacement = reindent(block.replace, baseIndent(block.search), baseIndent(lines.slice(start, end)));
|
|
131
|
+
return { ok: true, block, stage: "blank-insensitive", start, end, replacement };
|
|
132
|
+
}
|
|
133
|
+
/** 1 ブロックを段階フォールバック (STAGES → blank-insensitive) でマッチさせ、適用後の置換行も算出する */
|
|
93
134
|
export function matchBlock(lines, block) {
|
|
94
135
|
for (const { stage, eq } of STAGES) {
|
|
95
136
|
const found = findMatches(lines, block.search, eq);
|
|
@@ -105,6 +146,9 @@ export function matchBlock(lines, block) {
|
|
|
105
146
|
return { ok: false, block, reason: "ambiguous", stage, count: found.length, positions: found };
|
|
106
147
|
}
|
|
107
148
|
}
|
|
149
|
+
const blankInsensitive = matchBlankInsensitive(lines, block);
|
|
150
|
+
if (blankInsensitive !== null)
|
|
151
|
+
return blankInsensitive;
|
|
108
152
|
return { ok: false, block, reason: "not-found" };
|
|
109
153
|
}
|
|
110
154
|
/**
|
package/dist/core/nearest.js
CHANGED
|
@@ -6,13 +6,34 @@
|
|
|
6
6
|
* 2026-08-25 の実運用事例 (AI が失敗原因を空白/エンコーディングと誤診し 4 往復) を受けて追加。
|
|
7
7
|
* 抜粋行は verbatim で保持する — AI がそのまま次の SEARCH へコピーする前提のため、
|
|
8
8
|
* 制御文字の可視化などの加工は行わない (差分の説明は note 側に分離する)。
|
|
9
|
+
*
|
|
10
|
+
* 2026-08-25 追記 (空行欠落事例): スナップショット生成ツール側で空行が落ち、SEARCH に
|
|
11
|
+
* 空行が不足するだけで不一致になる事例が確認された。固定幅ウィンドウのスライド照合では
|
|
12
|
+
* 1 行の過不足で位置合わせ全体がずれ、別の類似箇所 (End Sub 等) を抜粋してしまうため、
|
|
13
|
+
* ギャップ (行の挿入・欠落) を許容するアラインメント (fit alignment) へ変更した。
|
|
14
|
+
* 実ファイル側にだけある行は "+" として抜粋に含め、空行なら専用の注記と修正ヒントを出す。
|
|
15
|
+
*
|
|
16
|
+
* 2026-08-30 追記: matcher に 4 段目 blank-insensitive が入り、空行の差だけなら SEARCH
|
|
17
|
+
* マッチングの段階で自動吸収されるようになった。この診断に空行差が現れるのは「空行以外にも
|
|
18
|
+
* 差がある」ケースだけなので、注記は空行が原因と読めない文言にする (誤誘導防止)。
|
|
9
19
|
*/
|
|
10
20
|
/** 前後のコンテキストとして抜粋に含める行数 */
|
|
11
|
-
const CONTEXT_LINES =
|
|
21
|
+
const CONTEXT_LINES = 4;
|
|
12
22
|
/** レポートに載せるウィンドウの最大行数 (巨大 SEARCH の暴走防止) */
|
|
13
23
|
const MAX_RENDER_LINES = 40;
|
|
14
24
|
/** 「存在しない行」一覧の最大表示数 */
|
|
15
25
|
const MAX_MISSING_LINES = 8;
|
|
26
|
+
/** アラインメント DP のセル数上限 (巨大ファイル × 巨大 SEARCH のメモリ暴走防止) */
|
|
27
|
+
const MAX_DP_CELLS = 20_000_000;
|
|
28
|
+
/**
|
|
29
|
+
* アラインメントのスコア。置換 (SUB) は gap 2 個 (削除+挿入) より高くする —
|
|
30
|
+
* 内容の異なる行同士でも位置が対応するなら「!」として同じ行に並べて見せたいため。
|
|
31
|
+
*/
|
|
32
|
+
const SCORE_MATCH = 6; // 前後空白無視で一致
|
|
33
|
+
const SCORE_WS = 4; // 行内空白の圧縮まで許すと一致
|
|
34
|
+
const SCORE_FOLD = 2; // 不可視文字の畳み込みまで許すと一致
|
|
35
|
+
const SCORE_SUB = -2; // 内容の異なる行同士の対応付け
|
|
36
|
+
const GAP = -3; // 片側にしかない行 1 行あたり
|
|
16
37
|
const collapseWs = (s) => s.replace(/[ \t]+/g, " ");
|
|
17
38
|
/**
|
|
18
39
|
* 目視で区別しづらい文字を通常の文字へ畳み込む。畳み込み後に一致するなら、
|
|
@@ -46,26 +67,113 @@ function describeFirstDiff(fileLine, searchLine) {
|
|
|
46
67
|
};
|
|
47
68
|
return `${i + 1} 文字目から異なります (実ファイル側: ${cp(a, i)} / SEARCH 側: ${cp(b, i)})`;
|
|
48
69
|
}
|
|
49
|
-
function noteFor(fileLine, searchLine, verdict) {
|
|
70
|
+
function noteFor(fileLine, searchLine, verdict, subject) {
|
|
50
71
|
if (verdict === "ws-only") {
|
|
51
72
|
return "行内の連続空白の個数のみが異なります (実ファイル側の行をそのままコピーしてください)";
|
|
52
73
|
}
|
|
53
74
|
if (verdict === "differ") {
|
|
54
75
|
const ft = fileLine.trim();
|
|
55
76
|
const st = searchLine.trim();
|
|
77
|
+
if (ft === "" && st !== "") {
|
|
78
|
+
return `実ファイル側のこの行は空行です (空行の位置・数の差は自動で吸収されるため、失敗の原因は空行ではなく他の行の差にあります)`;
|
|
79
|
+
}
|
|
80
|
+
if (st === "" && ft !== "") {
|
|
81
|
+
return `${subject} 側の対応行は空行ですが、実ファイルのこの行は空行ではありません (実ファイル側の行をそのままコピーしてください。空行の差は自動で吸収されます)`;
|
|
82
|
+
}
|
|
56
83
|
if (collapseWs(foldConfusable(ft)) === collapseWs(foldConfusable(st))) {
|
|
57
84
|
return `見た目で区別しづらい文字の差です。${describeFirstDiff(fileLine, searchLine)}`;
|
|
58
85
|
}
|
|
59
86
|
}
|
|
60
87
|
return undefined;
|
|
61
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* fit alignment: SEARCH 全行を実ファイルのどこかの連続領域に、行の挿入・欠落 (gap) を
|
|
91
|
+
* 許容しつつ最良スコアで対応付ける。局所アラインメントと違い SEARCH 側は必ず全行を
|
|
92
|
+
* 消費する (先頭・末尾の不一致行も「!」として抜粋に残すため)。
|
|
93
|
+
* 実ファイル側の開始位置は自由 (H[i][0] = 0)、同点なら最初 (最も上) の領域を採用。
|
|
94
|
+
*/
|
|
95
|
+
function alignSearch(fTrim, sTrimAll, fColl, sCollAll, fFold, sFoldAll) {
|
|
96
|
+
const n = fTrim.length;
|
|
97
|
+
if (n === 0 || sTrimAll.length === 0)
|
|
98
|
+
return null;
|
|
99
|
+
// セル数上限を超える場合は SEARCH 側を先頭から切り詰める (残りは unmatched 扱い)
|
|
100
|
+
const m = Math.min(sTrimAll.length, Math.max(1, Math.floor(MAX_DP_CELLS / n)));
|
|
101
|
+
const pairScore = (fi, sj) => {
|
|
102
|
+
if (fTrim[fi] === sTrimAll[sj])
|
|
103
|
+
return SCORE_MATCH;
|
|
104
|
+
if (fColl[fi] === sCollAll[sj])
|
|
105
|
+
return SCORE_WS;
|
|
106
|
+
if (fFold[fi] === sFoldAll[sj])
|
|
107
|
+
return SCORE_FOLD;
|
|
108
|
+
return SCORE_SUB;
|
|
109
|
+
};
|
|
110
|
+
// 1 = diag (pair), 2 = up (実ファイル側の挿入行), 3 = left (SEARCH 側の欠落行)
|
|
111
|
+
const dirs = new Uint8Array(n * m);
|
|
112
|
+
let prev = new Int32Array(m + 1);
|
|
113
|
+
let curr = new Int32Array(m + 1);
|
|
114
|
+
for (let j = 1; j <= m; j++)
|
|
115
|
+
prev[j] = j * GAP;
|
|
116
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
117
|
+
let bestI = -1;
|
|
118
|
+
for (let i = 1; i <= n; i++) {
|
|
119
|
+
curr[0] = 0;
|
|
120
|
+
for (let j = 1; j <= m; j++) {
|
|
121
|
+
const diag = prev[j - 1] + pairScore(i - 1, j - 1);
|
|
122
|
+
const up = prev[j] + GAP;
|
|
123
|
+
const left = curr[j - 1] + GAP;
|
|
124
|
+
let v = diag;
|
|
125
|
+
let d = 1;
|
|
126
|
+
if (up > v) {
|
|
127
|
+
v = up;
|
|
128
|
+
d = 2;
|
|
129
|
+
}
|
|
130
|
+
if (left > v) {
|
|
131
|
+
v = left;
|
|
132
|
+
d = 3;
|
|
133
|
+
}
|
|
134
|
+
curr[j] = v;
|
|
135
|
+
dirs[(i - 1) * m + (j - 1)] = d;
|
|
136
|
+
}
|
|
137
|
+
if (curr[m] > bestScore) {
|
|
138
|
+
bestScore = curr[m];
|
|
139
|
+
bestI = i;
|
|
140
|
+
}
|
|
141
|
+
[prev, curr] = [curr, prev];
|
|
142
|
+
}
|
|
143
|
+
if (bestI < 0)
|
|
144
|
+
return null;
|
|
145
|
+
const rev = [];
|
|
146
|
+
let i = bestI;
|
|
147
|
+
let j = m;
|
|
148
|
+
while (j > 0) {
|
|
149
|
+
if (i === 0) {
|
|
150
|
+
rev.push({ kind: "missing", searchIdx: j - 1 });
|
|
151
|
+
j--;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const d = dirs[(i - 1) * m + (j - 1)];
|
|
155
|
+
if (d === 1) {
|
|
156
|
+
rev.push({ kind: "pair", fileIdx: i - 1, searchIdx: j - 1 });
|
|
157
|
+
i--;
|
|
158
|
+
j--;
|
|
159
|
+
}
|
|
160
|
+
else if (d === 2) {
|
|
161
|
+
rev.push({ kind: "extra", fileIdx: i - 1 });
|
|
162
|
+
i--;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
rev.push({ kind: "missing", searchIdx: j - 1 });
|
|
166
|
+
j--;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { steps: rev.reverse(), clippedSearchFrom: m };
|
|
170
|
+
}
|
|
62
171
|
/**
|
|
63
172
|
* SEARCH に最も近い実ファイル領域を探す。
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* 走査が O(ファイル行数 × SEARCH 行数) になるため、正規化はすべて事前計算する。
|
|
173
|
+
* 行の対応付けはギャップ許容アラインメント (alignSearch)。1 行も (空白圧縮・不可視文字の
|
|
174
|
+
* 畳み込みでも) 一致しないときのみ window は null。
|
|
67
175
|
*/
|
|
68
|
-
export function analyzeNearest(fileLines, search, afterPriorBlocks = false) {
|
|
176
|
+
export function analyzeNearest(fileLines, search, afterPriorBlocks = false, subject = "SEARCH") {
|
|
69
177
|
const fTrim = fileLines.map((l) => l.trim());
|
|
70
178
|
const sTrim = search.map((l) => l.trim());
|
|
71
179
|
const fColl = fTrim.map(collapseWs);
|
|
@@ -83,59 +191,132 @@ export function analyzeNearest(fileLines, search, afterPriorBlocks = false) {
|
|
|
83
191
|
count++;
|
|
84
192
|
lineHits.push({ index: i + 1, text, count });
|
|
85
193
|
});
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 };
|
|
194
|
+
const aligned = alignSearch(fTrim, sTrim, fColl, sColl, fFold, sFold);
|
|
195
|
+
if (aligned === null)
|
|
196
|
+
return { lineHits, window: null, afterPriorBlocks, subject };
|
|
197
|
+
const { steps, clippedSearchFrom } = aligned;
|
|
198
|
+
// どの段の一致も 1 行も無いアラインメントは領域として無意味 (従来の null 条件を維持)
|
|
199
|
+
const anyHit = steps.some((s) => s.kind === "pair" &&
|
|
200
|
+
(fTrim[s.fileIdx] === sTrim[s.searchIdx] ||
|
|
201
|
+
fColl[s.fileIdx] === sColl[s.searchIdx] ||
|
|
202
|
+
fFold[s.fileIdx] === sFold[s.searchIdx]));
|
|
203
|
+
if (!anyHit)
|
|
204
|
+
return { lineHits, window: null, afterPriorBlocks, subject };
|
|
205
|
+
const fileIdxs = steps.flatMap((s) => (s.kind === "missing" ? [] : [s.fileIdx]));
|
|
206
|
+
const startFile = Math.min(...fileIdxs);
|
|
207
|
+
const endFile = Math.max(...fileIdxs) + 1;
|
|
113
208
|
const lines = [];
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
209
|
+
const unmatchedSearch = [];
|
|
210
|
+
let matchCount = 0;
|
|
211
|
+
let extraCount = 0;
|
|
212
|
+
let extraBlankCount = 0;
|
|
213
|
+
const pushContext = (from, to) => {
|
|
214
|
+
for (let i = from; i < to; i++) {
|
|
119
215
|
lines.push({ lineNo: i + 1, text: fileLines[i], verdict: null });
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
pushContext(Math.max(0, startFile - CONTEXT_LINES), startFile);
|
|
219
|
+
for (const step of steps) {
|
|
220
|
+
if (step.kind === "missing") {
|
|
221
|
+
unmatchedSearch.push({
|
|
222
|
+
index: step.searchIdx + 1,
|
|
223
|
+
text: search[step.searchIdx],
|
|
224
|
+
blank: sTrim[step.searchIdx] === "",
|
|
225
|
+
});
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (step.kind === "extra") {
|
|
229
|
+
extraCount++;
|
|
230
|
+
const blank = fTrim[step.fileIdx] === "";
|
|
231
|
+
if (blank)
|
|
232
|
+
extraBlankCount++;
|
|
233
|
+
const note = blank
|
|
234
|
+
? `実ファイル側にある空行です (空行の位置・数の差は自動で吸収されるため、この空行自体は失敗の原因ではありません)`
|
|
235
|
+
: `${subject} に含まれていない行です。この行も含めてコピーしてください`;
|
|
236
|
+
lines.push({
|
|
237
|
+
lineNo: step.fileIdx + 1,
|
|
238
|
+
text: fileLines[step.fileIdx],
|
|
239
|
+
verdict: "extra",
|
|
240
|
+
note,
|
|
241
|
+
});
|
|
120
242
|
continue;
|
|
121
243
|
}
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
244
|
+
const verdict = verdictOf(fTrim[step.fileIdx], sTrim[step.searchIdx]);
|
|
245
|
+
if (verdict === "match")
|
|
246
|
+
matchCount++;
|
|
247
|
+
const note = noteFor(fileLines[step.fileIdx], search[step.searchIdx], verdict, subject);
|
|
125
248
|
lines.push({
|
|
126
|
-
lineNo:
|
|
127
|
-
text: fileLines[
|
|
249
|
+
lineNo: step.fileIdx + 1,
|
|
250
|
+
text: fileLines[step.fileIdx],
|
|
128
251
|
verdict,
|
|
129
252
|
...(note !== undefined ? { note } : {}),
|
|
130
253
|
});
|
|
131
254
|
}
|
|
255
|
+
pushContext(endFile, Math.min(fileLines.length, endFile + CONTEXT_LINES));
|
|
256
|
+
// DP セル数上限で切り詰めた SEARCH 末尾は対応なし扱い
|
|
257
|
+
for (let k = clippedSearchFrom; k < search.length; k++) {
|
|
258
|
+
unmatchedSearch.push({ index: k + 1, text: search[k], blank: sTrim[k] === "" });
|
|
259
|
+
}
|
|
132
260
|
return {
|
|
133
261
|
lineHits,
|
|
134
|
-
window: {
|
|
262
|
+
window: {
|
|
263
|
+
total: search.length,
|
|
264
|
+
matchCount,
|
|
265
|
+
lines,
|
|
266
|
+
extraCount,
|
|
267
|
+
extraBlankCount,
|
|
268
|
+
unmatchedSearch,
|
|
269
|
+
},
|
|
135
270
|
afterPriorBlocks,
|
|
271
|
+
subject,
|
|
136
272
|
};
|
|
137
273
|
}
|
|
138
|
-
const MARK = { match: "=", "ws-only": "~", differ: "!" };
|
|
274
|
+
const MARK = { match: "=", "ws-only": "~", differ: "!", extra: "+" };
|
|
275
|
+
/** 抜粋 (コードブロック + 凡例 + 行ごとの注記)。SEARCH / REPLACE 両方の診断で共用 */
|
|
276
|
+
function renderWindowBody(w, subject) {
|
|
277
|
+
const out = ["```"];
|
|
278
|
+
for (const line of w.lines.slice(0, MAX_RENDER_LINES)) {
|
|
279
|
+
const mark = line.verdict === null ? " " : MARK[line.verdict];
|
|
280
|
+
out.push(`${mark} ${String(line.lineNo).padStart(5)}│${line.text}`);
|
|
281
|
+
}
|
|
282
|
+
if (w.lines.length > MAX_RENDER_LINES) {
|
|
283
|
+
out.push(`… (以下 ${w.lines.length - MAX_RENDER_LINES} 行省略)`);
|
|
284
|
+
}
|
|
285
|
+
out.push("```", `凡例: ! = ${subject} と内容が異なる行 / ~ = 空白の個数のみ異なる行 / + = 実ファイルにあるが ${subject} にない行 / = = 一致した行 / 無印 = 前後の文脈`);
|
|
286
|
+
for (const line of w.lines) {
|
|
287
|
+
if (line.note !== undefined)
|
|
288
|
+
out.push(`- ${line.lineNo} 行目: ${line.note}`);
|
|
289
|
+
}
|
|
290
|
+
for (const u of w.unmatchedSearch) {
|
|
291
|
+
out.push(u.blank
|
|
292
|
+
? `- ${subject} ${u.index} 行目の空行に対応する行がこの領域にありません (${subject} 側だけの空行は自動で吸収されるため、失敗の原因は他の行にあります)`
|
|
293
|
+
: `- ${subject} ${u.index} 行目 (${u.text.trim()}) に対応する行がこの領域にありません`);
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* 修正のヒント (near-miss 自動サジェスト)。SEARCH の全行が順序どおり実ファイルに存在し、
|
|
299
|
+
* 間に SEARCH 側に無い行が挟まっているだけなら「抜粋のコピーで一致する」と機械的に言える。
|
|
300
|
+
* 空行だけの過不足は blank-insensitive 段階で自動吸収されるため、このヒントが空行のみを
|
|
301
|
+
* 指すのは行内空白差 (~) が併発しているときだけ — 原因を空行と誤読させない文言にする。
|
|
302
|
+
*/
|
|
303
|
+
function buildHint(w) {
|
|
304
|
+
if (w.extraCount === 0 || w.unmatchedSearch.length > 0)
|
|
305
|
+
return [];
|
|
306
|
+
if (w.lines.some((l) => l.verdict === "differ"))
|
|
307
|
+
return [];
|
|
308
|
+
const hasWsOnly = w.lines.some((l) => l.verdict === "ws-only");
|
|
309
|
+
const head = w.extraBlankCount === w.extraCount
|
|
310
|
+
? hasWsOnly
|
|
311
|
+
? `▶ 修正のヒント: 間に挟まる空行 ${w.extraCount} 行の差は自動で吸収されます。この失敗の原因は空行ではなく、空白の個数のみ異なる行 (~) です。`
|
|
312
|
+
: `▶ 修正のヒント: SEARCH の全行はこの順で実ファイルに存在しますが、間に空行 ${w.extraCount} 行が挟まっています。`
|
|
313
|
+
: `▶ 修正のヒント: SEARCH の全行はこの順で実ファイルに存在しますが、間に SEARCH にない行が ${w.extraCount} 行 (うち空行 ${w.extraBlankCount} 行) 挟まっています。空行の差は自動で吸収されますが、内容のある行の欠落は吸収されません。`;
|
|
314
|
+
return [
|
|
315
|
+
"",
|
|
316
|
+
head,
|
|
317
|
+
"上の抜粋の「│」より右側を、+ の行も含めてそのままコピーした SEARCH に置き換えれば一致します。",
|
|
318
|
+
];
|
|
319
|
+
}
|
|
139
320
|
/** 失敗レポートに埋め込む診断テキスト。抜粋行は「│」の右側が実ファイルの verbatim */
|
|
140
321
|
export function renderNearest(a) {
|
|
141
322
|
const out = ["#### 参考: 実ファイルの該当箇所 (petari が現在のファイル内容から自動抽出)"];
|
|
@@ -161,18 +342,26 @@ export function renderNearest(a) {
|
|
|
161
342
|
return out;
|
|
162
343
|
}
|
|
163
344
|
const w = a.window;
|
|
164
|
-
out.push("", `最も近い領域 (SEARCH ${w.total} 行中 ${w.matchCount} 行が一致)
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
}
|
|
345
|
+
out.push("", `最も近い領域 (SEARCH ${w.total} 行中 ${w.matchCount} 行が一致):`);
|
|
346
|
+
out.push(...renderWindowBody(w, a.subject));
|
|
347
|
+
out.push(...buildHint(w));
|
|
177
348
|
return out;
|
|
178
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* SEARCH がどの行も見つからなかったときの補助診断: REPLACE 側の内容に近い実ファイル領域。
|
|
352
|
+
* 「本来変更を当てたかった箇所」の推定と、変更が既に別の形で入っている可能性の提示に使う。
|
|
353
|
+
*/
|
|
354
|
+
export function renderNearestReplace(a) {
|
|
355
|
+
if (a.window === null)
|
|
356
|
+
return [];
|
|
357
|
+
const w = a.window;
|
|
358
|
+
return [
|
|
359
|
+
"#### 参考: REPLACE 側の内容に近い実ファイル領域",
|
|
360
|
+
"(SEARCH はどの行も見つかりませんでしたが、REPLACE の内容に近い箇所が実ファイルにあります。",
|
|
361
|
+
"この変更が既に別の形で適用されているか、SEARCH の基準にしたスナップショットが古い可能性が",
|
|
362
|
+
"あります。変更がまだ必要な場合は、以下の抜粋を現在の内容として SEARCH を作り直してください)",
|
|
363
|
+
"",
|
|
364
|
+
`最も近い領域 (REPLACE ${w.total} 行中 ${w.matchCount} 行が一致):`,
|
|
365
|
+
...renderWindowBody(w, a.subject),
|
|
366
|
+
];
|
|
367
|
+
}
|
package/dist/core/report.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { STAGE_LABEL } from "./matcher.js";
|
|
2
|
-
import { renderNearest } from "./nearest.js";
|
|
2
|
+
import { renderNearest, renderNearestReplace } from "./nearest.js";
|
|
3
3
|
const MATCH_SPEC_NOTE = [
|
|
4
4
|
"## petari の照合仕様 (SEARCH を修正する前に必ず読んでください)",
|
|
5
5
|
"",
|
|
6
|
-
"- petari は行末空白・インデントの深さ (タブ/スペース混在含む)
|
|
7
|
-
" (UTF-8 / Shift_JIS) の違いを自動で吸収して照合しています",
|
|
8
|
-
"-
|
|
9
|
-
"
|
|
10
|
-
"-
|
|
6
|
+
"- petari は行末空白・インデントの深さ (タブ/スペース混在含む)・空行の位置と数・改行コード・",
|
|
7
|
+
" 文字コード (UTF-8 / Shift_JIS) の違いを自動で吸収して照合しています",
|
|
8
|
+
"- 空行の差は双方から空行を除いた照合で吸収されます。ただし空行を除くと複数箇所に一致して",
|
|
9
|
+
" しまう場合は曖昧エラーになります (前後の行を追加して一意に特定できる範囲にしてください)",
|
|
10
|
+
"- したがって「SEARCH が見つかりません」は空白・空行・インデント・エンコーディングの問題では",
|
|
11
|
+
" ありません。内容のある行の文字内容か、その過不足が現在のファイルと異なっています",
|
|
12
|
+
" (抜粋では実ファイル側にだけある行を + で示します)",
|
|
13
|
+
"- 行内空白や空行の調整・ASCII 行だけへの縮小・別アンカーへの乗り換えでは解決しません。各失敗に",
|
|
11
14
|
" 添付した「実ファイルの該当箇所」の抜粋から、行をそのままコピーしてください",
|
|
12
15
|
];
|
|
13
16
|
const RE_REQUEST = `## 依頼
|
|
@@ -15,7 +18,8 @@ const RE_REQUEST = `## 依頼
|
|
|
15
18
|
上記の失敗した各ブロックについて、SEARCH 部分を現在のファイル内容と完全に一致するよう修正し、
|
|
16
19
|
changes.md 全体を元の規約フォーマット (## CHANGES から始まる形式) で再出力してください。
|
|
17
20
|
- SEARCH の修正には「実ファイルの該当箇所」の抜粋を使い、「│」より右側を一字一句そのまま
|
|
18
|
-
コピーしてください (行頭の「! ~
|
|
21
|
+
コピーしてください (行頭の「! ~ = +」の記号と行番号は含めません。+ の行 (空行含む) も
|
|
22
|
+
SEARCH に含めます)
|
|
19
23
|
- SEARCH ブロックにはファイル内で一意に特定できる範囲を含めてください
|
|
20
24
|
- 失敗していないファイル・ブロックも含めた完全な changes.md を出力してください
|
|
21
25
|
- 抜粋にも SEARCH に相当する行が見当たらない場合は、推測で書き換えず、その旨を報告して
|
|
@@ -62,8 +66,56 @@ function outcomeSummary(outcomes) {
|
|
|
62
66
|
lines.push(` - ブロック ${index}: ${status}`);
|
|
63
67
|
}
|
|
64
68
|
}
|
|
69
|
+
lines.push(...remainingSummary(outcomes));
|
|
65
70
|
return lines;
|
|
66
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* 「残り何ブロックを直せば全適用できるか」の構造化サマリ (§7)。
|
|
74
|
+
* all-or-nothing の設計上、あと 1 ブロックで全通しの状態を明示すると再出力の
|
|
75
|
+
* 修正ポイントが絞れる (2026-08-25 実運用フィードバック)。
|
|
76
|
+
*/
|
|
77
|
+
function remainingSummary(outcomes) {
|
|
78
|
+
let totalUnits = 0;
|
|
79
|
+
let failedUnits = 0;
|
|
80
|
+
for (const o of outcomes) {
|
|
81
|
+
if (o.change.op === "replace" && o.totalBlocks > 0) {
|
|
82
|
+
totalUnits += o.totalBlocks;
|
|
83
|
+
const fileLevel = o.failures.some((f) => f.block === undefined);
|
|
84
|
+
failedUnits += fileLevel ? o.totalBlocks : o.failures.length;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
totalUnits += 1;
|
|
88
|
+
if (o.failures.length > 0)
|
|
89
|
+
failedUnits += 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (failedUnits === 0 || failedUnits >= totalUnits)
|
|
93
|
+
return [];
|
|
94
|
+
const passed = totalUnits - failedUnits;
|
|
95
|
+
return [
|
|
96
|
+
"",
|
|
97
|
+
failedUnits === 1
|
|
98
|
+
? `→ 失敗は全 ${totalUnits} ブロック中この 1 ブロックだけです。この 1 ブロックの SEARCH を修正すれば全体が適用可能になります。`
|
|
99
|
+
: `→ 全 ${totalUnits} ブロック中 ${passed} ブロックは検証を通過しています。残る ${failedUnits} ブロックの SEARCH を修正すれば全体が適用可能になります。`,
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* blank-insensitive (空行差無視) で一致したブロックがあるときの注記 (§6)。
|
|
104
|
+
* 空行の並びが SEARCH の指定どおりにならない可能性を dry-run と適用サマリの末尾で知らせる。
|
|
105
|
+
* 該当ブロックが無ければ null。
|
|
106
|
+
*/
|
|
107
|
+
export function buildBlankInsensitiveNote(outcomes) {
|
|
108
|
+
let count = 0;
|
|
109
|
+
for (const o of outcomes) {
|
|
110
|
+
for (const b of o.appliedBlocks) {
|
|
111
|
+
if (b.stage === "blank-insensitive")
|
|
112
|
+
count++;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (count === 0)
|
|
116
|
+
return null;
|
|
117
|
+
return `注: ${count} 件のブロックは空行の差を吸収して適用しました (git diff で空行の並びを確認してください)`;
|
|
118
|
+
}
|
|
67
119
|
/** 検証失敗 (マッチング・パス・エンコーディング) のレポート */
|
|
68
120
|
export function buildFailureReport(failures, ctx = {}) {
|
|
69
121
|
const parts = ["以下の変更ブロックが現在のコードベースに適用できませんでした。"];
|
|
@@ -83,6 +135,9 @@ export function buildFailureReport(failures, ctx = {}) {
|
|
|
83
135
|
if (f.nearest !== undefined) {
|
|
84
136
|
parts.push("", ...renderNearest(f.nearest));
|
|
85
137
|
}
|
|
138
|
+
if (f.nearestReplace !== undefined) {
|
|
139
|
+
parts.push("", ...renderNearestReplace(f.nearestReplace));
|
|
140
|
+
}
|
|
86
141
|
}
|
|
87
142
|
parts.push("", RE_REQUEST, "");
|
|
88
143
|
return parts.join("\n");
|