petari 0.9.1 → 0.10.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 +6 -1
- package/dist/commands/apply.js +37 -2
- package/dist/commands/list.js +2 -1
- package/dist/commands/undo.js +9 -1
- package/dist/core/applier.js +8 -1
- package/dist/core/fingerprint.js +23 -0
- package/dist/core/matcher.js +57 -0
- package/dist/core/report.js +3 -1
- package/dist/infra/history.js +46 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -94,7 +94,8 @@ slnmix の出力) が実ファイルとずれている**可能性が高いです
|
|
|
94
94
|
|
|
95
95
|
主なオプション: `--dry-run` (検証と差分プレビューのみ) / `--partial` (成功分のみ適用) /
|
|
96
96
|
`--root <dir>` / `--yes` / `--clip-report` (失敗レポートをクリップボードへ。v0.5.0 から既定で自動コピーされるため、config で無効化した場合の個別指定用) /
|
|
97
|
-
`--no-vbproj` (create した .vb の .vbproj 自動登録を今回だけ無効化)
|
|
97
|
+
`--no-vbproj` (create した .vb の .vbproj 自動登録を今回だけ無効化) /
|
|
98
|
+
`--force` (適用済みの changes.md でも再適用する。下記「二重適用の防止」を今回だけ解除)
|
|
98
99
|
|
|
99
100
|
## 特徴
|
|
100
101
|
|
|
@@ -102,7 +103,11 @@ slnmix の出力) が実ファイルとずれている**可能性が高いです
|
|
|
102
103
|
- **冪等 (再実行に安全)**: SEARCH が見つからなくても REPLACE の内容が既にファイルに
|
|
103
104
|
存在すれば「適用済み」として成功扱いでスキップ。同じ changes.md を 2 回実行しても
|
|
104
105
|
失敗せず、適用済み・未適用が混在していても未適用分だけを適用する。
|
|
106
|
+
SEARCH の前後に行を足す追記型の変更も、足す行が既にあれば「適用済み」になる。
|
|
105
107
|
結果は `適用 / 済み / 失敗` の 3 状態で表示
|
|
108
|
+
- **二重適用の防止**: 適用した changes.md の指紋を履歴に記録し、同じ changes.md を
|
|
109
|
+
もう一度渡して書き込みが発生する場合は何も書かずに止める (ファイル名・前置き・改行コードが
|
|
110
|
+
違っても同じ変更なら検出)。`petari undo` で戻したものは再適用でき、それ以外は `--force` で再適用
|
|
106
111
|
- **エンコーディング保全**: Shift_JIS / UTF-8 (BOM 有無)・CRLF / LF・末尾改行を完全維持。
|
|
107
112
|
変更していない行は元のバイト列をそのまま書き戻す (レガシー VB.NET 資産でも安全)
|
|
108
113
|
- **規約逸脱への耐性 (寛容パース)**: AI がマーカーの記号数・見出しレベルを崩したり
|
package/dist/commands/apply.js
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
7
7
|
import { join, resolve } from "node:path";
|
|
8
8
|
import { parseArgs } from "node:util";
|
|
9
9
|
import { invalidPathReason, planChangeSet, } from "../core/applier.js";
|
|
10
|
+
import { changeSetFingerprint } from "../core/fingerprint.js";
|
|
10
11
|
import { PRESENCE_STAGE_LABEL } from "../core/matcher.js";
|
|
11
12
|
import { fixedNewFile, resolveNewFileStyle } from "../core/new-file-style.js";
|
|
12
13
|
import { parseChangesRecovering } from "../core/parser.js";
|
|
@@ -18,7 +19,7 @@ import { deleteFile, isInsideRoot, readFileState, sha256, writeBytes } from "../
|
|
|
18
19
|
import { gitDirtyFiles } from "../infra/git.js";
|
|
19
20
|
import { collectStyleSampleGroups } from "../infra/new-file-style.js";
|
|
20
21
|
import { err, out, outEmphasis } from "../infra/term.js";
|
|
21
|
-
import { beginHistory, createHistoryId, finishHistory, pruneHistory, } from "../infra/history.js";
|
|
22
|
+
import { beginHistory, createHistoryId, findAppliedHistory, finishHistory, pruneHistory, } from "../infra/history.js";
|
|
22
23
|
import { confirm } from "../infra/prompt.js";
|
|
23
24
|
import { findProjectRoot } from "../infra/root.js";
|
|
24
25
|
import { formatVbprojPlan, planVbprojRegistrations } from "./vbproj-register.js";
|
|
@@ -67,7 +68,8 @@ function printAlreadyApplied(outcomes) {
|
|
|
67
68
|
continue;
|
|
68
69
|
}
|
|
69
70
|
for (const b of o.alreadyAppliedBlocks) {
|
|
70
|
-
|
|
71
|
+
const what = b.insertion === true ? "追記する行が SEARCH の前後に既に存在" : "REPLACE が既に存在";
|
|
72
|
+
out(` replace ${o.change.path} — ブロック ${b.block.index}: ${what} (${PRESENCE_STAGE_LABEL[b.stage]})`);
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
}
|
|
@@ -83,6 +85,8 @@ export async function applyCommand(argv) {
|
|
|
83
85
|
"clip-report": { type: "boolean", default: false },
|
|
84
86
|
// create した .vb の .vbproj 自動登録を今回だけ無効化 (config vbproj.register の一時上書き)
|
|
85
87
|
"no-vbproj": { type: "boolean", default: false },
|
|
88
|
+
// 履歴で適用済みの changes.md でも再適用する (二重適用防止の解除・§6.1)
|
|
89
|
+
force: { type: "boolean", default: false },
|
|
86
90
|
},
|
|
87
91
|
allowPositionals: true,
|
|
88
92
|
});
|
|
@@ -190,6 +194,14 @@ export async function applyCommand(argv) {
|
|
|
190
194
|
out(` ${r.line} 行目: ${r.message}`);
|
|
191
195
|
out("");
|
|
192
196
|
}
|
|
197
|
+
// 同じ changes.md を以前に適用していないか (取り違え・二重実行の検出・§6.1)。
|
|
198
|
+
// 旧形式の履歴は保存済みの changes.md 原本をパースして指紋を算出する
|
|
199
|
+
const fingerprint = changeSetFingerprint(changeSet);
|
|
200
|
+
const previousId = findAppliedHistory(root, fingerprint, (text) => {
|
|
201
|
+
const r = parseChangesRecovering(stripBom(text));
|
|
202
|
+
return r.issues.length > 0 ? null : changeSetFingerprint(r.changeSet);
|
|
203
|
+
});
|
|
204
|
+
const previousNote = previousId !== null ? `この changes.md は適用済みです (履歴 ID: ${previousId})` : null;
|
|
193
205
|
// 2. 全ブロックのドライラン検証
|
|
194
206
|
const states = new Map();
|
|
195
207
|
for (const f of changeSet.files) {
|
|
@@ -224,6 +236,8 @@ export async function applyCommand(argv) {
|
|
|
224
236
|
// 3. 失敗があれば何も書き込まずレポート (§4.1, §7)
|
|
225
237
|
if (!plan.ok && !values.partial) {
|
|
226
238
|
err(`petari: 検証に失敗しました (${plan.failures.length} 件)。何も書き込んでいません。\n`);
|
|
239
|
+
if (previousNote !== null)
|
|
240
|
+
err(`注: ${previousNote}。取り違えていないか確認してください\n`);
|
|
227
241
|
await emitReport(buildFailureReport(plan.failures, { outcomes: plan.outcomes, nothingWritten: true }));
|
|
228
242
|
return 1;
|
|
229
243
|
}
|
|
@@ -246,6 +260,8 @@ export async function applyCommand(argv) {
|
|
|
246
260
|
// 冪等性: 全変更が適用済みなら成功として終了 (書き込み・履歴なし)
|
|
247
261
|
if (plan.ok && plan.outcomes.some((o) => o.alreadyApplied)) {
|
|
248
262
|
out("すべての変更は適用済みです。書き込みは行いませんでした。");
|
|
263
|
+
if (previousNote !== null)
|
|
264
|
+
out(` (${previousNote})`);
|
|
249
265
|
for (const o of plan.outcomes)
|
|
250
266
|
out(` ${opLabel(o)}`);
|
|
251
267
|
return 0;
|
|
@@ -254,6 +270,24 @@ export async function applyCommand(argv) {
|
|
|
254
270
|
await emitReport(buildFailureReport(plan.failures, { outcomes: plan.outcomes, nothingWritten: true }));
|
|
255
271
|
return 1;
|
|
256
272
|
}
|
|
273
|
+
// 適用済みの changes.md で、なお書き込みが発生する = 二重適用のおそれ。--force なしでは止める
|
|
274
|
+
if (previousNote !== null) {
|
|
275
|
+
if (values.force) {
|
|
276
|
+
out(`--force: ${previousNote}。再適用します`);
|
|
277
|
+
}
|
|
278
|
+
else if (values["dry-run"]) {
|
|
279
|
+
out(`警告: ${previousNote}。実際の適用は --force を付けない限り止まります`);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
err(`petari: ${previousNote}。何も書き込んでいません。`);
|
|
283
|
+
err(" このまま適用すると次のファイルに書き込みが発生します (二重適用のおそれ):");
|
|
284
|
+
for (const o of applicable)
|
|
285
|
+
err(` ${opLabel(o)}`);
|
|
286
|
+
err(" 取り違えでなければ --force を付けて再実行してください");
|
|
287
|
+
err(" (petari undo で巻き戻した履歴は対象外です。git 等で手動で戻した場合は --force が必要です)");
|
|
288
|
+
return 1;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
257
291
|
if (values["dry-run"]) {
|
|
258
292
|
out(`dry-run: 適用予定 ${applicable.length} ファイル (書き込みなし)`);
|
|
259
293
|
printPreview(applicable);
|
|
@@ -368,6 +402,7 @@ export async function applyCommand(argv) {
|
|
|
368
402
|
success: plan.ok,
|
|
369
403
|
partial: values.partial,
|
|
370
404
|
source,
|
|
405
|
+
changesFingerprint: fingerprint,
|
|
371
406
|
files: entries,
|
|
372
407
|
};
|
|
373
408
|
finishHistory(root, id, manifest, after);
|
package/dist/commands/list.js
CHANGED
|
@@ -63,7 +63,8 @@ export async function listCommand(argv) {
|
|
|
63
63
|
continue;
|
|
64
64
|
}
|
|
65
65
|
const appliedCount = m.files.filter((f) => f.applied).length;
|
|
66
|
-
const status = m.success ? "成功" : m.partial ? "部分適用" : "失敗"
|
|
66
|
+
const status = (m.success ? "成功" : m.partial ? "部分適用" : "失敗") +
|
|
67
|
+
(m.undoneAt !== undefined ? " (巻き戻し済み)" : "");
|
|
67
68
|
const summary = summaryLine(dir);
|
|
68
69
|
out(`${id} ${status} ${appliedCount} ファイル ${summary}`);
|
|
69
70
|
}
|
package/dist/commands/undo.js
CHANGED
|
@@ -10,7 +10,7 @@ import { parseArgs } from "node:util";
|
|
|
10
10
|
import { invalidPathReason } from "../core/applier.js";
|
|
11
11
|
import { loadConfig } from "../infra/config.js";
|
|
12
12
|
import { deleteFile, isInsideRoot, readFileState, sha256, writeBytes } from "../infra/files.js";
|
|
13
|
-
import { historyRoot, listHistoryIds, readManifest } from "../infra/history.js";
|
|
13
|
+
import { historyRoot, listHistoryIds, markUndone, readManifest, } from "../infra/history.js";
|
|
14
14
|
import { confirm } from "../infra/prompt.js";
|
|
15
15
|
import { findProjectRoot } from "../infra/root.js";
|
|
16
16
|
import { err, out } from "../infra/term.js";
|
|
@@ -103,5 +103,13 @@ export async function undoCommand(argv) {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
out(`巻き戻しました (履歴 ID: ${id})`);
|
|
106
|
+
// 巻き戻した changes.md は再適用できるよう、二重適用検出の対象から外す (§6.1)
|
|
107
|
+
try {
|
|
108
|
+
markUndone(root, id, new Date());
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
err(`petari: 履歴 ${id} に巻き戻し済みの印を付けられませんでした (${e instanceof Error ? e.message : String(e)})。` +
|
|
112
|
+
"同じ changes.md を再適用するには --force が必要です");
|
|
113
|
+
}
|
|
106
114
|
return 0;
|
|
107
115
|
}
|
package/dist/core/applier.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EncodingError, decodeFile, encodeDocument, findUnencodable, makeDocument, } from "./encoding.js";
|
|
2
|
-
import { STAGE_LABEL, contentEqualsStage, findContentStage, matchBlock, } from "./matcher.js";
|
|
2
|
+
import { STAGE_LABEL, contentEqualsStage, findContentStage, findInsertionPresenceStage, matchBlock, } from "./matcher.js";
|
|
3
3
|
import { analyzeNearest } from "./nearest.js";
|
|
4
4
|
/** Windows の予約デバイス名 (拡張子付きも不可) */
|
|
5
5
|
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..+)?$/i;
|
|
@@ -111,6 +111,13 @@ function planReplace(change, state, fallbackEol) {
|
|
|
111
111
|
continue;
|
|
112
112
|
}
|
|
113
113
|
const replacement = m.replacement;
|
|
114
|
+
// 冪等性 (§6.1): 追記型ブロックは適用後も SEARCH が一致し続けるため、追記する行が
|
|
115
|
+
// 一致範囲の前後に既にあれば「適用済み」としてスキップする (再実行での重複追記を防ぐ)
|
|
116
|
+
const insertionStage = findInsertionPresenceStage(textLines, m.start, m.end, replacement === block.replace ? [replacement] : [replacement, block.replace]);
|
|
117
|
+
if (insertionStage !== null) {
|
|
118
|
+
already.push({ block, stage: insertionStage, insertion: true });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
114
121
|
const bad = findUnencodable(replacement.join("\n"), doc.encoding);
|
|
115
122
|
if (bad.length > 0) {
|
|
116
123
|
failures.push({
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* changes.md の指紋 (§5, §6.1)。同じ changes.md の再適用 (取り違え・二重実行) を
|
|
3
|
+
* 履歴と突き合わせて止めるために使う。
|
|
4
|
+
*
|
|
5
|
+
* パース後の変更内容 (パス・操作・ブロック/本文の行) だけから算出する。
|
|
6
|
+
* チャットの前置き・CHANGES 概要・改行コード・寛容パースで補正したマーカー表記の
|
|
7
|
+
* 違いは指紋に影響しない (同じ変更なら同じ指紋)。
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
export function changeSetFingerprint(changeSet) {
|
|
11
|
+
const normalized = changeSet.files.map((f) => {
|
|
12
|
+
switch (f.op) {
|
|
13
|
+
case "replace":
|
|
14
|
+
return { path: f.path, op: f.op, blocks: f.blocks.map((b) => [b.search, b.replace]) };
|
|
15
|
+
case "create":
|
|
16
|
+
case "rewrite":
|
|
17
|
+
return { path: f.path, op: f.op, content: f.content };
|
|
18
|
+
case "delete":
|
|
19
|
+
return { path: f.path, op: f.op };
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
return createHash("sha256").update(JSON.stringify(normalized), "utf8").digest("hex");
|
|
23
|
+
}
|
package/dist/core/matcher.js
CHANGED
|
@@ -32,6 +32,63 @@ export function findContentStage(lines, content) {
|
|
|
32
32
|
}
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* 追記型ブロックの適用済み判定 (§6.1)。SEARCH が [start, end) に一意に一致したうえで、
|
|
37
|
+
* 挿入しようとしている行 (candidates のいずれか) が「一致範囲を内側に含む形で」既に
|
|
38
|
+
* ファイルにあるかを調べる。REPLACE が SEARCH を包含する追記型 (SEARCH `A` → REPLACE
|
|
39
|
+
* `A` + `B`) は適用後も SEARCH が一致し続けるため、SEARCH 不一致を起点とする
|
|
40
|
+
* findContentStage では拾えず、再実行で `B` が重複していた。
|
|
41
|
+
*
|
|
42
|
+
* - 比較は空行を除いた行列で行う (空行の欠落・追加だけの差は同一視)。空行まで含めて
|
|
43
|
+
* 一致すれば exact / trim-end、空行の並びだけ異なれば blank-insensitive を返す
|
|
44
|
+
* - 各行の比較は exact → trim-end のみ。インデント違いは別物として扱う
|
|
45
|
+
* (入れ子の End If を補う変更を、隣の外側の End If を根拠に「済み」と誤判定しないため)
|
|
46
|
+
* - 一致範囲の外に内容のある行を 1 行以上足すものだけが対象。SEARCH と同数以下
|
|
47
|
+
* (インデント修正などの書き換え) は null
|
|
48
|
+
*
|
|
49
|
+
* candidates には reindent 済みの置換行と REPLACE の原文を渡す (前回 trim-all で一致して
|
|
50
|
+
* 補正後の行が入っている場合と、原文のまま入っている場合の両方を拾うため)。
|
|
51
|
+
*/
|
|
52
|
+
export function findInsertionPresenceStage(lines, start, end, candidates) {
|
|
53
|
+
const nonBlank = [];
|
|
54
|
+
lines.forEach((line, i) => {
|
|
55
|
+
if (line.trim() !== "")
|
|
56
|
+
nonBlank.push(i);
|
|
57
|
+
});
|
|
58
|
+
const first = nonBlank.findIndex((i) => i >= start);
|
|
59
|
+
const matched = nonBlank.filter((i) => i >= start && i < end).length;
|
|
60
|
+
if (first < 0 || matched === 0)
|
|
61
|
+
return null;
|
|
62
|
+
for (const { stage, eq } of STAGES.slice(0, 2)) {
|
|
63
|
+
for (const rep of candidates) {
|
|
64
|
+
const repIdx = [];
|
|
65
|
+
rep.forEach((line, i) => {
|
|
66
|
+
if (line.trim() !== "")
|
|
67
|
+
repIdx.push(i);
|
|
68
|
+
});
|
|
69
|
+
if (repIdx.length <= matched)
|
|
70
|
+
continue;
|
|
71
|
+
// q = REPLACE 内で一致範囲 (の最初の非空行) が始まる位置 (非空行単位)
|
|
72
|
+
for (let q = 0; q + matched <= repIdx.length; q++) {
|
|
73
|
+
const from = first - q;
|
|
74
|
+
if (from < 0 || from + repIdx.length > nonBlank.length)
|
|
75
|
+
continue;
|
|
76
|
+
const all = repIdx.every((r, j) => eq(lines[nonBlank[from + j]], rep[r]));
|
|
77
|
+
if (!all)
|
|
78
|
+
continue;
|
|
79
|
+
// 空行の並びまで同じか (ラベル用)
|
|
80
|
+
const lo = nonBlank[from];
|
|
81
|
+
const hi = nonBlank[from + repIdx.length - 1] + 1;
|
|
82
|
+
const repLo = repIdx[0];
|
|
83
|
+
const repHi = repIdx[repIdx.length - 1] + 1;
|
|
84
|
+
const sameLayout = hi - lo === repHi - repLo &&
|
|
85
|
+
lines.slice(lo, hi).every((line, k) => eq(line, rep[repLo + k]));
|
|
86
|
+
return sameLayout ? stage : "blank-insensitive";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
35
92
|
/** 適用済み判定 (rewrite/create): content がファイル全行と一致するか */
|
|
36
93
|
export function contentEqualsStage(lines, content) {
|
|
37
94
|
if (lines.length !== content.length)
|
package/dist/core/report.js
CHANGED
|
@@ -50,7 +50,9 @@ function outcomeSummary(outcomes) {
|
|
|
50
50
|
byIndex.set(b.block.index, `OK 一致 (${STAGE_LABEL[b.stage]})`);
|
|
51
51
|
}
|
|
52
52
|
for (const b of o.alreadyAppliedBlocks) {
|
|
53
|
-
byIndex.set(b.block.index,
|
|
53
|
+
byIndex.set(b.block.index, b.insertion === true
|
|
54
|
+
? "OK 適用済み (追記する行が SEARCH の前後に既に存在)"
|
|
55
|
+
: "OK 適用済み (REPLACE が既に存在)");
|
|
54
56
|
}
|
|
55
57
|
for (const f of o.failures) {
|
|
56
58
|
if (f.block === undefined)
|
package/dist/infra/history.js
CHANGED
|
@@ -69,4 +69,50 @@ export function readManifest(root, id) {
|
|
|
69
69
|
return null;
|
|
70
70
|
return JSON.parse(readFileSync(p, "utf8"));
|
|
71
71
|
}
|
|
72
|
+
/** undo の完了を manifest に記録する (再適用検出で「巻き戻し済み」を区別するため) */
|
|
73
|
+
export function markUndone(root, id, at) {
|
|
74
|
+
const p = join(historyRoot(root), id, "manifest.json");
|
|
75
|
+
const manifest = JSON.parse(readFileSync(p, "utf8"));
|
|
76
|
+
manifest.undoneAt = at.toISOString();
|
|
77
|
+
writeFileSync(p, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 同じ指紋の changes.md を適用した履歴 (巻き戻し済み・書き込みなしを除く) を新しい順に
|
|
81
|
+
* 探し、最初に見つかった履歴 ID を返す。指紋を持たない旧形式の manifest は、保存済みの
|
|
82
|
+
* changes.md 原本から fallback で算出する (null = 算出不能として対象外)。
|
|
83
|
+
* manifest は非信頼入力のため、読めない・形が違うものは黙って対象外にする。
|
|
84
|
+
*/
|
|
85
|
+
export function findAppliedHistory(root, fingerprint, fallback) {
|
|
86
|
+
for (const id of listHistoryIds(root).reverse()) {
|
|
87
|
+
let manifest;
|
|
88
|
+
try {
|
|
89
|
+
manifest = readManifest(root, id);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (manifest === null || typeof manifest !== "object")
|
|
95
|
+
continue;
|
|
96
|
+
if (manifest.undoneAt !== undefined)
|
|
97
|
+
continue;
|
|
98
|
+
if (!Array.isArray(manifest.files) || !manifest.files.some((f) => f?.applied === true))
|
|
99
|
+
continue;
|
|
100
|
+
let fp = null;
|
|
101
|
+
if (typeof manifest.changesFingerprint === "string") {
|
|
102
|
+
fp = manifest.changesFingerprint;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
const p = join(historyRoot(root), id, "changes.md");
|
|
106
|
+
try {
|
|
107
|
+
fp = fallback(readFileSync(p, "utf8"));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
fp = null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (fp === fingerprint)
|
|
114
|
+
return id;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
72
118
|
export { sha256 };
|