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 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 がマーカーの記号数・見出しレベルを崩したり
@@ -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
- out(` replace ${o.change.path} — ブロック ${b.block.index}: REPLACE が既に存在 (${PRESENCE_STAGE_LABEL[b.stage]})`);
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);
@@ -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
  }
@@ -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
  }
@@ -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
+ }
@@ -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)
@@ -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, "OK 適用済み (REPLACE が既に存在)");
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)
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "petari",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "petari — paste AI chat patches onto your codebase",
5
5
  "keywords": [
6
6
  "cli",