petari 0.2.0 → 0.3.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
@@ -52,7 +52,7 @@ petari の更新で規約文が変わった場合は、`petari init` を再実
52
52
 
53
53
  | コマンド | 説明 |
54
54
  |---|---|
55
- | `petari [path]` | changes.md を適用。省略時は Downloads から自動検出 (直近 30 分)。`--clip` でクリップボードから |
55
+ | `petari [path]` | changes.md を適用。省略時は Downloads とプロジェクト直下から自動検出 (直近 30 分)。`--clip` でクリップボードから |
56
56
  | `petari undo [ID]` | 履歴を巻き戻す (手修正がある場合は警告) |
57
57
  | `petari show <ID>` | VS Code の差分ビューで履歴を表示。`--mine` で適用後の手修正分、`--changes` で変更概要 |
58
58
  | `petari list` | 履歴の一覧と合計サイズ |
@@ -65,6 +65,10 @@ petari の更新で規約文が変わった場合は、`petari init` を再実
65
65
  ## 特徴
66
66
 
67
67
  - **all-or-nothing**: 全ブロックを事前検証し、1 つでも失敗があれば何も書き込まない
68
+ - **冪等 (再実行に安全)**: SEARCH が見つからなくても REPLACE の内容が既にファイルに
69
+ 存在すれば「適用済み」として成功扱いでスキップ。同じ changes.md を 2 回実行しても
70
+ 失敗せず、適用済み・未適用が混在していても未適用分だけを適用する。
71
+ 結果は `適用 / 済み / 失敗` の 3 状態で表示
68
72
  - **エンコーディング保全**: Shift_JIS / UTF-8 (BOM 有無)・CRLF / LF・末尾改行を完全維持。
69
73
  変更していない行は元のバイト列をそのまま書き戻す (レガシー VB.NET 資産でも安全)
70
74
  - **失敗レポート**: そのまま AI チャットに貼り返せる形式で出力。再依頼が 1 コピペで済む
@@ -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 { PRESENCE_STAGE_LABEL } from "../core/matcher.js";
10
11
  import { parseChanges } from "../core/parser.js";
11
12
  import { buildFailureReport, buildParseErrorReport } from "../core/report.js";
12
13
  import { readClipboard, writeClipboard } from "../infra/clipboard.js";
@@ -18,17 +19,25 @@ import { err, out } from "../infra/term.js";
18
19
  import { beginHistory, createHistoryId, finishHistory, pruneHistory, } from "../infra/history.js";
19
20
  import { confirm } from "../infra/prompt.js";
20
21
  import { findProjectRoot } from "../infra/root.js";
21
- /** --partial 時: このファイルは書き込み対象か */
22
+ /** このファイルは書き込み対象か (適用済みのみのファイルは書き込み不要なので含めない) */
22
23
  function isApplicable(o) {
23
- if (o.failures.length === 0)
24
- return true;
25
- return o.change.op === "replace" && o.appliedBlocks.length > 0;
24
+ if (o.alreadyApplied)
25
+ return false;
26
+ if (o.change.op === "replace")
27
+ return o.appliedBlocks.length > 0;
28
+ return o.failures.length === 0;
26
29
  }
27
30
  function opLabel(o) {
28
31
  const c = o.change;
29
- if (c.op === "replace")
30
- return `replace ${c.path} — ${o.appliedBlocks.length}/${o.totalBlocks} ブロック`;
31
- return `${c.op.padEnd(7)} ${c.path}`;
32
+ if (c.op === "replace") {
33
+ const already = o.alreadyAppliedBlocks.length;
34
+ const detail = already > 0
35
+ ? `適用 ${o.appliedBlocks.length} / 済み ${already} / 全 ${o.totalBlocks} ブロック`
36
+ : `${o.appliedBlocks.length}/${o.totalBlocks} ブロック`;
37
+ return `replace ${c.path} — ${detail}`;
38
+ }
39
+ const label = `${c.op.padEnd(7)} ${c.path}`;
40
+ return o.alreadyApplied ? `${label} — 適用済み (スキップ)` : label;
32
41
  }
33
42
  function printPreview(outcomes) {
34
43
  for (const o of outcomes) {
@@ -42,6 +51,23 @@ function printPreview(outcomes) {
42
51
  }
43
52
  }
44
53
  }
54
+ /** 適用済みとしてスキップした内容の一覧 (冪等性)。該当がなければ何も出さない */
55
+ function printAlreadyApplied(outcomes) {
56
+ const files = outcomes.filter((o) => o.alreadyApplied || o.alreadyAppliedBlocks.length > 0);
57
+ if (files.length === 0)
58
+ return;
59
+ out("適用済みのためスキップ:");
60
+ for (const o of files) {
61
+ if (o.change.op !== "replace") {
62
+ const why = o.change.op === "delete" ? "既に存在しません" : "現在の内容と一致";
63
+ out(` ${o.change.op.padEnd(7)} ${o.change.path} — ${why}`);
64
+ continue;
65
+ }
66
+ for (const b of o.alreadyAppliedBlocks) {
67
+ out(` replace ${o.change.path} — ブロック ${b.block.index}: REPLACE が既に存在 (${PRESENCE_STAGE_LABEL[b.stage]})`);
68
+ }
69
+ }
70
+ }
45
71
  export async function applyCommand(argv) {
46
72
  const { values, positionals } = parseArgs({
47
73
  args: argv,
@@ -104,15 +130,24 @@ export async function applyCommand(argv) {
104
130
  source = { type: "clipboard" };
105
131
  }
106
132
  else {
133
+ // Downloads とプロジェクトルート直下の両方から候補を集め、最新を採用する
107
134
  const downloadsDir = await resolveDownloadsDir(config.downloadsDir);
108
- const candidates = findRecentChangesFiles(downloadsDir, new Date());
135
+ const now = new Date();
136
+ const tagged = [
137
+ ...findRecentChangesFiles(downloadsDir, now).map((c) => ({ ...c, origin: "downloads" })),
138
+ ...findRecentChangesFiles(root, now).map((c) => ({ ...c, origin: "project-root" })),
139
+ ];
140
+ const seen = new Set();
141
+ const candidates = tagged
142
+ .filter((c) => !seen.has(c.path) && (seen.add(c.path), true))
143
+ .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
109
144
  if (candidates.length === 0) {
110
- err(`petari: ${downloadsDir} に直近 30 分以内の changes*.md が見つかりません`);
145
+ err(`petari: ${downloadsDir} にもプロジェクト直下にも直近 30 分以内の changes*.md が見つかりません`);
111
146
  err(" ファイルパスを指定するか、--clip でクリップボードから読み込んでください");
112
147
  return 1;
113
148
  }
114
149
  const newest = candidates[0];
115
- out(`Downloads から検出: ${newest.path}`);
150
+ out(`${newest.origin === "downloads" ? "Downloads" : "プロジェクト直下"}から検出: ${newest.path}`);
116
151
  if (candidates.length > 1) {
117
152
  out(` (他 ${candidates.length - 1} 件の候補があります。最新を使用します)`);
118
153
  for (const c of candidates.slice(1))
@@ -129,7 +164,7 @@ export async function applyCommand(argv) {
129
164
  err(`petari: ファイルを読めません: ${newest.path}`);
130
165
  return 1;
131
166
  }
132
- source = { type: "downloads", path: newest.path };
167
+ source = { type: newest.origin, path: newest.path };
133
168
  }
134
169
  // 1. パース (構文エラーは即時失敗・§4.1)
135
170
  const { changeSet, issues } = parseChanges(changesText);
@@ -162,6 +197,13 @@ export async function applyCommand(argv) {
162
197
  }
163
198
  const applicable = plan.outcomes.filter(isApplicable);
164
199
  if (applicable.length === 0) {
200
+ // 冪等性: 全変更が適用済みなら成功として終了 (書き込み・履歴なし)
201
+ if (plan.ok && plan.outcomes.some((o) => o.alreadyApplied)) {
202
+ out("すべての変更は適用済みです。書き込みは行いませんでした。");
203
+ for (const o of plan.outcomes)
204
+ out(` ${opLabel(o)}`);
205
+ return 0;
206
+ }
165
207
  err("petari: 適用できる変更がありません。\n");
166
208
  await emitReport(buildFailureReport(plan.failures));
167
209
  return 1;
@@ -169,6 +211,7 @@ export async function applyCommand(argv) {
169
211
  if (values["dry-run"]) {
170
212
  out(`dry-run: 適用予定 ${applicable.length} ファイル (書き込みなし)`);
171
213
  printPreview(applicable);
214
+ printAlreadyApplied(plan.outcomes);
172
215
  if (plan.failures.length > 0) {
173
216
  out("");
174
217
  await emitReport(buildFailureReport(plan.failures));
@@ -180,6 +223,7 @@ export async function applyCommand(argv) {
180
223
  out(`適用予定 ${applicable.length} ファイル:`);
181
224
  for (const o of applicable)
182
225
  out(` ${opLabel(o)}`);
226
+ printAlreadyApplied(plan.outcomes);
183
227
  if (plan.failures.length > 0) {
184
228
  out(` (検証失敗 ${plan.failures.length} 件は --partial によりスキップ)`);
185
229
  }
@@ -230,6 +274,11 @@ export async function applyCommand(argv) {
230
274
  };
231
275
  if (skipped.length > 0)
232
276
  entry.skippedBlocks = skipped;
277
+ if (o.alreadyAppliedBlocks.length > 0) {
278
+ entry.alreadyAppliedBlocks = o.alreadyAppliedBlocks.map((b) => b.block.index);
279
+ }
280
+ if (o.alreadyApplied)
281
+ entry.alreadyApplied = true;
233
282
  return entry;
234
283
  });
235
284
  const manifest = {
@@ -250,16 +299,18 @@ export async function applyCommand(argv) {
250
299
  out(`適用しました (履歴 ID: ${id})`);
251
300
  for (const o of applicable)
252
301
  out(` ${opLabel(o)}`);
302
+ printAlreadyApplied(plan.outcomes);
253
303
  if (plan.failures.length > 0) {
254
304
  out(`スキップした失敗 ${plan.failures.length} 件のレポート:`);
255
305
  out("");
256
306
  await emitReport(buildFailureReport(plan.failures));
257
307
  }
258
- // 6. Downloads 由来なら changes.md を履歴へ移動 (原本は history に保存済み・§4.1)
259
- if (source.type === "downloads" && source.path !== undefined) {
308
+ // 6. 自動検出 (Downloads / プロジェクト直下) 由来なら changes.md を履歴へ移動
309
+ // (原本は history に保存済み・§4.1)。全件適用済みで履歴を作らなかった場合は削除しない
310
+ if ((source.type === "downloads" || source.type === "project-root") && source.path !== undefined) {
260
311
  try {
261
312
  deleteFile(source.path);
262
- out(`Downloads の ${source.path} を履歴へ移動しました`);
313
+ out(`${source.path} を履歴へ移動しました`);
263
314
  }
264
315
  catch {
265
316
  err(`petari: ${source.path} を削除できませんでした (履歴には保存済み)`);
@@ -1,5 +1,5 @@
1
1
  import { EncodingError, decodeFile, encodeDocument, findUnencodable, makeDocument, } from "./encoding.js";
2
- import { STAGE_LABEL, matchBlock } from "./matcher.js";
2
+ import { STAGE_LABEL, contentEqualsStage, findContentStage, matchBlock, } from "./matcher.js";
3
3
  /** Windows の予約デバイス名 (拡張子付きも不可) */
4
4
  const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..+)?$/i;
5
5
  /** §9: プロジェクトルート相対のみ。絶対パス・`..`・`~`・Windows 特殊パスを拒否 */
@@ -28,9 +28,23 @@ function fileFailure(change, kind, message) {
28
28
  failures: [{ path: change.path, kind, message }],
29
29
  afterBytes: null,
30
30
  appliedBlocks: [],
31
+ alreadyAppliedBlocks: [],
32
+ alreadyApplied: false,
31
33
  totalBlocks: change.op === "replace" ? change.blocks.length : 0,
32
34
  };
33
35
  }
36
+ /** rewrite / create / delete がファイル単位で適用済みだったときの成功 outcome */
37
+ function fileAlreadyApplied(change) {
38
+ return {
39
+ change,
40
+ failures: [],
41
+ afterBytes: null,
42
+ appliedBlocks: [],
43
+ alreadyAppliedBlocks: [],
44
+ alreadyApplied: true,
45
+ totalBlocks: 0,
46
+ };
47
+ }
34
48
  function planReplace(change, state, fallbackEol) {
35
49
  let doc;
36
50
  try {
@@ -41,14 +55,28 @@ function planReplace(change, state, fallbackEol) {
41
55
  }
42
56
  const failures = [];
43
57
  const applied = [];
58
+ const already = [];
44
59
  let docLines = doc.lines;
45
60
  let textLines = docLines.map((l) => l.text);
46
61
  for (const block of change.blocks) {
47
62
  const m = matchBlock(textLines, block);
48
63
  if (!m.ok) {
64
+ // 冪等性 (§6): SEARCH 不一致でも REPLACE がまるごと存在すれば「適用済み」として
65
+ // 成功扱いでスキップする (書き込みなし)。空・空行のみの REPLACE は判定対象外
66
+ if (m.reason === "not-found") {
67
+ const stage = findContentStage(textLines, block.replace);
68
+ if (stage !== null) {
69
+ already.push({ block, stage });
70
+ continue;
71
+ }
72
+ }
73
+ const checkedReplace = block.replace.some((l) => l.trim() !== "");
49
74
  const message = m.reason === "ambiguous"
50
75
  ? `ブロック ${block.index}: SEARCH が ${m.count} 箇所にマッチしました (${STAGE_LABEL[m.stage]})。一意に特定できる範囲を含めてください`
51
- : `ブロック ${block.index}: SEARCH が現在のファイル内容に見つかりません`;
76
+ : `ブロック ${block.index}: SEARCH が現在のファイル内容に見つかりません` +
77
+ (checkedReplace
78
+ ? " (REPLACE の内容も見つからないため、changes.md の基準スナップショットが現在のコードベースとずれている可能性があります)"
79
+ : "");
52
80
  failures.push({ path: change.path, kind: m.reason === "ambiguous" ? "block-ambiguous" : "block-not-found", message, block });
53
81
  continue;
54
82
  }
@@ -80,6 +108,8 @@ function planReplace(change, state, fallbackEol) {
80
108
  failures,
81
109
  afterBytes,
82
110
  appliedBlocks: applied,
111
+ alreadyAppliedBlocks: already,
112
+ alreadyApplied: change.blocks.length > 0 && failures.length === 0 && already.length === change.blocks.length,
83
113
  totalBlocks: change.blocks.length,
84
114
  };
85
115
  }
@@ -96,6 +126,18 @@ function planFile(change, state, newFile) {
96
126
  switch (change.op) {
97
127
  case "create": {
98
128
  if (state.exists) {
129
+ // 冪等性: 既存ファイルの内容が CONTENT と一致するなら適用済みとしてスキップ
130
+ if (state.bytes !== null) {
131
+ try {
132
+ const doc = decodeFile(state.bytes);
133
+ if (contentEqualsStage(doc.lines.map((l) => l.text), change.content) !== null) {
134
+ return fileAlreadyApplied(change);
135
+ }
136
+ }
137
+ catch {
138
+ // デコード不能なら判定せず従来のエラーへ
139
+ }
140
+ }
99
141
  return fileFailure(change, "target-exists", "create 指定ですがファイルが既に存在します (全文置き換えなら rewrite を使用)");
100
142
  }
101
143
  const bad = findUnencodable(change.content.join("\n"), newFile.encoding);
@@ -103,7 +145,15 @@ function planFile(change, state, newFile) {
103
145
  return fileFailure(change, "unencodable", `新規ファイルの既定エンコーディング (${newFile.encoding}) に変換できない文字: ${bad.join(" ")}`);
104
146
  }
105
147
  const doc = makeDocument(change.content, newFile);
106
- return { change, failures: [], afterBytes: encodeDocument(doc), appliedBlocks: [], totalBlocks: 0 };
148
+ return {
149
+ change,
150
+ failures: [],
151
+ afterBytes: encodeDocument(doc),
152
+ appliedBlocks: [],
153
+ alreadyAppliedBlocks: [],
154
+ alreadyApplied: false,
155
+ totalBlocks: 0,
156
+ };
107
157
  }
108
158
  case "rewrite": {
109
159
  if (!state.exists) {
@@ -116,6 +166,10 @@ function planFile(change, state, newFile) {
116
166
  catch (e) {
117
167
  return fileFailure(change, "undecodable", e instanceof EncodingError ? e.message : String(e));
118
168
  }
169
+ // 冪等性: 全文が現在の内容と一致するなら適用済みとしてスキップ
170
+ if (contentEqualsStage(doc.lines.map((l) => l.text), change.content) !== null) {
171
+ return fileAlreadyApplied(change);
172
+ }
119
173
  const bad = findUnencodable(change.content.join("\n"), doc.encoding);
120
174
  if (bad.length > 0) {
121
175
  return fileFailure(change, "unencodable", `Shift_JIS へ変換できない文字が含まれています: ${bad.join(" ")}`);
@@ -123,13 +177,30 @@ function planFile(change, state, newFile) {
123
177
  // 既存のエンコーディング・BOM・支配的 EOL・末尾改行の有無を維持して全文を差し替える (§8)
124
178
  const lines = change.content.map((text) => ({ text, raw: null, eol: null }));
125
179
  const afterBytes = encodeDocument({ ...doc, lines });
126
- return { change, failures: [], afterBytes, appliedBlocks: [], totalBlocks: 0 };
180
+ return {
181
+ change,
182
+ failures: [],
183
+ afterBytes,
184
+ appliedBlocks: [],
185
+ alreadyAppliedBlocks: [],
186
+ alreadyApplied: false,
187
+ totalBlocks: 0,
188
+ };
127
189
  }
128
190
  case "delete": {
129
191
  if (!state.exists) {
130
- return fileFailure(change, "target-missing", "delete 指定ですがファイルが存在しません");
192
+ // 冪等性: 既に存在しないなら適用済みとしてスキップ
193
+ return fileAlreadyApplied(change);
131
194
  }
132
- return { change, failures: [], afterBytes: null, appliedBlocks: [], totalBlocks: 0 };
195
+ return {
196
+ change,
197
+ failures: [],
198
+ afterBytes: null,
199
+ appliedBlocks: [],
200
+ alreadyAppliedBlocks: [],
201
+ alreadyApplied: false,
202
+ totalBlocks: 0,
203
+ };
133
204
  }
134
205
  case "replace": {
135
206
  if (!state.exists) {
@@ -8,6 +8,39 @@ const STAGES = [
8
8
  { stage: "trim-end", eq: (a, b) => a.trimEnd() === b.trimEnd() },
9
9
  { stage: "trim-all", eq: (a, b) => a.trim() === b.trim() },
10
10
  ];
11
+ export const PRESENCE_STAGE_LABEL = {
12
+ ...STAGE_LABEL,
13
+ "ws-collapse": "空白圧縮一致",
14
+ };
15
+ const collapseWs = (s) => s.trim().replace(/[ \t]+/g, " ");
16
+ const PRESENCE_STAGES = [
17
+ ...STAGES,
18
+ { stage: "ws-collapse", eq: (a, b) => collapseWs(a) === collapseWs(b) },
19
+ ];
20
+ /**
21
+ * 適用済み判定: content ブロック全体 (複数行まるごと) が lines 内に存在するか。
22
+ * 見つかった最初の (最も厳密な) 段階を返す。空・空行のみの content は偶然一致
23
+ * しやすいため常に null (判定対象外 = 従来どおり失敗扱い)。
24
+ */
25
+ export function findContentStage(lines, content) {
26
+ if (!content.some((l) => l.trim() !== ""))
27
+ return null;
28
+ for (const { stage, eq } of PRESENCE_STAGES) {
29
+ if (findMatches(lines, content, eq).length > 0)
30
+ return stage;
31
+ }
32
+ return null;
33
+ }
34
+ /** 適用済み判定 (rewrite/create): content がファイル全行と一致するか */
35
+ export function contentEqualsStage(lines, content) {
36
+ if (lines.length !== content.length)
37
+ return null;
38
+ for (const { stage, eq } of PRESENCE_STAGES) {
39
+ if (lines.every((line, i) => eq(line, content[i])))
40
+ return stage;
41
+ }
42
+ return null;
43
+ }
11
44
  function findMatches(lines, search, eq) {
12
45
  const found = [];
13
46
  for (let i = 0; i + search.length <= lines.length; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "petari",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "petari — paste AI chat patches onto your codebase",
5
5
  "keywords": [
6
6
  "cli",