petari 0.9.1 → 0.11.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
@@ -32,12 +32,15 @@ petari undo # 直近の適用を巻き戻す
32
32
  `instructionFilePath` 相当。別の場所に置く場合は `--instruction-file <path>`)。
33
33
 
34
34
  ```sh
35
- npx petari init # protocol.md を生成 (.sln と同じプロジェクト直下に)
35
+ npx slnmix init # 初回だけ。protocol.md / .petari/ / docs/ / .gitignore をまとめて準備
36
+ # (slnmix v0.18.0 以降。内部で petari init --yes --no-repomix を実行)
36
37
  npx slnmix # .sln を解析。出力末尾に規約文が自動で付く
37
38
  # → 出力を M365 Copilot Chat に貼る
38
39
  petari # 返答 (changes.md) を適用
39
40
  ```
40
41
 
42
+ slnmix v0.17.0 以前では `npx petari init` で protocol.md を作ります (.sln と同じプロジェクト直下に)。
43
+
41
44
  **変更を依頼するメッセージには、必ず次の 1 行を添えてください。**
42
45
  貼り付け本文に埋め込まれた規約文は、チャットサービス側の要約・検索処理で
43
46
  モデルに届かなかったり、チャット本文の指示より弱く扱われたりすることがあります
@@ -90,11 +93,12 @@ slnmix の出力) が実ファイルとずれている**可能性が高いです
90
93
  | `petari show [ID]` | 履歴の差分を表示 (VS Code、なければブラウザにフォールバック)。`--edit` で現在のファイルを差分を見ながら手修正、`--mine` で適用後の手修正分、`--changes` で変更概要、`--browser` でブラウザ強制 |
91
94
  | `petari list` | 履歴の一覧と合計サイズ |
92
95
  | `petari protocol` | AI への規約文を標準出力に出す (slnmix / repomix 連携用) |
93
- | `petari init` | プロジェクト初回セットアップ (`--yes` で全提案に同意) |
96
+ | `petari init` | プロジェクト初回セットアップ (`--yes` で全提案に同意。`--no-repomix` で repomix 連携を省く。slnmix 利用者は `npx slnmix init` が代わりに呼ぶ) |
94
97
 
95
98
  主なオプション: `--dry-run` (検証と差分プレビューのみ) / `--partial` (成功分のみ適用) /
96
99
  `--root <dir>` / `--yes` / `--clip-report` (失敗レポートをクリップボードへ。v0.5.0 から既定で自動コピーされるため、config で無効化した場合の個別指定用) /
97
- `--no-vbproj` (create した .vb の .vbproj 自動登録を今回だけ無効化)
100
+ `--no-vbproj` (create した .vb の .vbproj 自動登録を今回だけ無効化) /
101
+ `--force` (適用済みの changes.md でも再適用する。下記「二重適用の防止」を今回だけ解除)
98
102
 
99
103
  ## 特徴
100
104
 
@@ -102,7 +106,11 @@ slnmix の出力) が実ファイルとずれている**可能性が高いです
102
106
  - **冪等 (再実行に安全)**: SEARCH が見つからなくても REPLACE の内容が既にファイルに
103
107
  存在すれば「適用済み」として成功扱いでスキップ。同じ changes.md を 2 回実行しても
104
108
  失敗せず、適用済み・未適用が混在していても未適用分だけを適用する。
109
+ SEARCH の前後に行を足す追記型の変更も、足す行が既にあれば「適用済み」になる。
105
110
  結果は `適用 / 済み / 失敗` の 3 状態で表示
111
+ - **二重適用の防止**: 適用した changes.md の指紋を履歴に記録し、同じ changes.md を
112
+ もう一度渡して書き込みが発生する場合は何も書かずに止める (ファイル名・前置き・改行コードが
113
+ 違っても同じ変更なら検出)。`petari undo` で戻したものは再適用でき、それ以外は `--force` で再適用
106
114
  - **エンコーディング保全**: Shift_JIS / UTF-8 (BOM 有無)・CRLF / LF・末尾改行を完全維持。
107
115
  変更していない行は元のバイト列をそのまま書き戻す (レガシー VB.NET 資産でも安全)
108
116
  - **規約逸脱への耐性 (寛容パース)**: AI がマーカーの記号数・見出しレベルを崩したり
package/dist/cli.js CHANGED
@@ -16,7 +16,8 @@ Usage:
16
16
  petari show [ID] 履歴の差分を表示 (VS Code / ブラウザ)。--edit で手修正
17
17
  petari list 履歴の一覧を表示
18
18
  petari protocol AI への規約文を標準出力に出す
19
- petari init プロジェクト初回セットアップ
19
+ petari init プロジェクト初回セットアップ (--yes で全提案に同意、
20
+ --no-repomix で repomix 連携を省く)
20
21
 
21
22
  Options:
22
23
  -h, --help ヘルプを表示
@@ -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);
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * petari init — プロジェクト初回セットアップの一括実行 (§4.6)。
3
3
  * 対話形式 (--yes で全提案に自動同意)。既存ファイルは上書きせず確認する。
4
+ * --no-repomix は repomix 連携 (手順 3) を丸ごと省く。slnmix の `slnmix init` が
5
+ * `--yes --no-repomix` で呼ぶ (slnmix 利用者に repomix.config.json は要らないため)。
4
6
  */
5
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
8
  import { join } from "node:path";
@@ -24,6 +26,7 @@ export async function initCommand(argv) {
24
26
  options: {
25
27
  root: { type: "string" },
26
28
  yes: { type: "boolean", short: "y", default: false },
29
+ "no-repomix": { type: "boolean", default: false },
27
30
  },
28
31
  });
29
32
  const root = findProjectRoot(process.cwd(), values.root);
@@ -57,7 +60,10 @@ export async function initCommand(argv) {
57
60
  }
58
61
  // 3. repomix 連携 (§4.6-3)
59
62
  const repomixPath = join(root, "repomix.config.json");
60
- if (existsSync(repomixPath)) {
63
+ if (values["no-repomix"]) {
64
+ out("3. repomix 連携は省略します (--no-repomix)");
65
+ }
66
+ else if (existsSync(repomixPath)) {
61
67
  let parsed;
62
68
  try {
63
69
  parsed = JSON.parse(readFileSync(repomixPath, "utf8"));
@@ -114,6 +120,10 @@ export async function initCommand(argv) {
114
120
  out(" 作成しました: .gitignore");
115
121
  }
116
122
  out("");
123
+ if (values["no-repomix"]) {
124
+ out("セットアップ完了。");
125
+ return 0;
126
+ }
117
127
  out("セットアップ完了。repomix 実行で規約文入りのコンテキストが生成されます。");
118
128
  out("slnmix (v0.7.0+) は同じディレクトリの protocol.md を出力末尾に自動連結します。");
119
129
  return 0;
@@ -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.11.0",
4
4
  "description": "petari — paste AI chat patches onto your codebase",
5
5
  "keywords": [
6
6
  "cli",