petari 0.9.0 → 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 がマーカーの記号数・見出しレベルを崩したり
@@ -6,7 +6,7 @@
6
6
  * 制約: slnmix (v0.7.0+) はこのテキストを <instruction>...</instruction> で囲んで
7
7
  * 出力へ連結するため、規約文に <instruction> という文字列を含めないこと。
8
8
  */
9
- export const PROTOCOL_VERSION = 3;
9
+ export const PROTOCOL_VERSION = 4;
10
10
  export const PROTOCOL_TEXT = `<!-- petari protocol v${PROTOCOL_VERSION} -->
11
11
  # コード変更の出力規約 (changes.md)
12
12
 
@@ -69,7 +69,9 @@ new code
69
69
  2. SEARCH ブロックの内容は、**現在のファイルから一字一句そのまま** (インデント・空白を含めて) コピーする。
70
70
  記憶に頼って書き換えたり要約したりしない
71
71
  3. SEARCH ブロックには **ファイル内で一意に特定できる範囲** を含める
72
- (同じコードが複数箇所にある場合は、前後の行を含めて一意にする)
72
+ (同じコードが複数箇所にある場合は、前後の行を含めて一意にする)。
73
+ ただし一意に特定できる範囲のうち **最小** (目安 3〜8 行) にする。大きな範囲は
74
+ 一致に失敗しやすい。ファイルの過半が変わるなら rewrite を使う
73
75
  4. パスはプロジェクトルートからの相対パスを / 区切りで書く。絶対パスや .. は使わない
74
76
  5. 同じファイルへの変更は 1 つの FILE セクションにまとめる (セクションを重複させない)
75
77
  6. FILE セクションの間に説明文を書かない。説明はすべて冒頭の CHANGES セクションに書く
@@ -7,7 +7,9 @@ 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";
12
+ import { fixedNewFile, resolveNewFileStyle } from "../core/new-file-style.js";
11
13
  import { parseChangesRecovering } from "../core/parser.js";
12
14
  import { buildBlankInsensitiveNote, buildFailureReport, buildParseErrorReport, } from "../core/report.js";
13
15
  import { readClipboard, writeClipboard } from "../infra/clipboard.js";
@@ -15,10 +17,12 @@ import { loadConfig } from "../infra/config.js";
15
17
  import { findRecentChangesFiles, resolveDownloadsDir, } from "../infra/downloads.js";
16
18
  import { deleteFile, isInsideRoot, readFileState, sha256, writeBytes } from "../infra/files.js";
17
19
  import { gitDirtyFiles } from "../infra/git.js";
20
+ import { collectStyleSampleGroups } from "../infra/new-file-style.js";
18
21
  import { err, out, outEmphasis } from "../infra/term.js";
19
- import { beginHistory, createHistoryId, finishHistory, pruneHistory, } from "../infra/history.js";
22
+ import { beginHistory, createHistoryId, findAppliedHistory, finishHistory, pruneHistory, } from "../infra/history.js";
20
23
  import { confirm } from "../infra/prompt.js";
21
24
  import { findProjectRoot } from "../infra/root.js";
25
+ import { formatVbprojPlan, planVbprojRegistrations } from "./vbproj-register.js";
22
26
  /** このファイルは書き込み対象か (適用済みのみのファイルは書き込み不要なので含めない) */
23
27
  function isApplicable(o) {
24
28
  if (o.alreadyApplied)
@@ -64,7 +68,8 @@ function printAlreadyApplied(outcomes) {
64
68
  continue;
65
69
  }
66
70
  for (const b of o.alreadyAppliedBlocks) {
67
- 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]})`);
68
73
  }
69
74
  }
70
75
  }
@@ -78,6 +83,10 @@ export async function applyCommand(argv) {
78
83
  yes: { type: "boolean", short: "y", default: false },
79
84
  clip: { type: "boolean", default: false },
80
85
  "clip-report": { type: "boolean", default: false },
86
+ // create した .vb の .vbproj 自動登録を今回だけ無効化 (config vbproj.register の一時上書き)
87
+ "no-vbproj": { type: "boolean", default: false },
88
+ // 履歴で適用済みの changes.md でも再適用する (二重適用防止の解除・§6.1)
89
+ force: { type: "boolean", default: false },
81
90
  },
82
91
  allowPositionals: true,
83
92
  });
@@ -185,6 +194,14 @@ export async function applyCommand(argv) {
185
194
  out(` ${r.line} 行目: ${r.message}`);
186
195
  out("");
187
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;
188
205
  // 2. 全ブロックのドライラン検証
189
206
  const states = new Map();
190
207
  for (const f of changeSet.files) {
@@ -200,18 +217,51 @@ export async function applyCommand(argv) {
200
217
  }
201
218
  states.set(f.path, readFileState(abs));
202
219
  }
203
- const plan = planChangeSet(changeSet, states, config.newFile);
220
+ // 新規ファイルの形式: encoding "auto" なら作成先の既存ファイルから推定する (設計書 §11.2)
221
+ const newFileBasis = new Map();
222
+ const resolveNewFile = (change) => {
223
+ if (config.newFile.encoding !== "auto" || change.op !== "create") {
224
+ return fixedNewFile(config.newFile);
225
+ }
226
+ // 不正パス・symlink 経由でルート外を指すパスは手本を探さない (検証段階で失敗する)
227
+ if (invalidPathReason(change.path) !== null || states.get(change.path)?.escapesRoot === true) {
228
+ return fixedNewFile(config.newFile);
229
+ }
230
+ const r = resolveNewFileStyle(change.path, config.newFile, collectStyleSampleGroups(root, change.path));
231
+ if (r.basis !== null)
232
+ newFileBasis.set(change.path, r.basis);
233
+ return r.config;
234
+ };
235
+ const plan = planChangeSet(changeSet, states, resolveNewFile);
204
236
  // 3. 失敗があれば何も書き込まずレポート (§4.1, §7)
205
237
  if (!plan.ok && !values.partial) {
206
238
  err(`petari: 検証に失敗しました (${plan.failures.length} 件)。何も書き込んでいません。\n`);
239
+ if (previousNote !== null)
240
+ err(`注: ${previousNote}。取り違えていないか確認してください\n`);
207
241
  await emitReport(buildFailureReport(plan.failures, { outcomes: plan.outcomes, nothingWritten: true }));
208
242
  return 1;
209
243
  }
210
244
  const applicable = plan.outcomes.filter(isApplicable);
245
+ // create した .vb の旧スタイル .vbproj への登録計画 (設計書 §11.1)。失敗しても create は成功扱い
246
+ const vbprojEnabled = config.vbproj.register && !values["no-vbproj"];
247
+ const vbprojPlan = vbprojEnabled
248
+ ? planVbprojRegistrations(root, applicable)
249
+ : { writes: new Map(), registrations: [], notes: [] };
250
+ const printExtras = () => {
251
+ for (const o of applicable) {
252
+ const basis = newFileBasis.get(o.change.path);
253
+ if (basis !== undefined)
254
+ out(` 新規ファイルの形式: ${o.change.path} → ${basis}`);
255
+ }
256
+ for (const line of formatVbprojPlan(vbprojPlan))
257
+ out(line);
258
+ };
211
259
  if (applicable.length === 0) {
212
260
  // 冪等性: 全変更が適用済みなら成功として終了 (書き込み・履歴なし)
213
261
  if (plan.ok && plan.outcomes.some((o) => o.alreadyApplied)) {
214
262
  out("すべての変更は適用済みです。書き込みは行いませんでした。");
263
+ if (previousNote !== null)
264
+ out(` (${previousNote})`);
215
265
  for (const o of plan.outcomes)
216
266
  out(` ${opLabel(o)}`);
217
267
  return 0;
@@ -220,9 +270,28 @@ export async function applyCommand(argv) {
220
270
  await emitReport(buildFailureReport(plan.failures, { outcomes: plan.outcomes, nothingWritten: true }));
221
271
  return 1;
222
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
+ }
223
291
  if (values["dry-run"]) {
224
292
  out(`dry-run: 適用予定 ${applicable.length} ファイル (書き込みなし)`);
225
293
  printPreview(applicable);
294
+ printExtras();
226
295
  printAlreadyApplied(plan.outcomes);
227
296
  const dryRunNote = buildBlankInsensitiveNote(plan.outcomes);
228
297
  if (dryRunNote !== null)
@@ -238,6 +307,7 @@ export async function applyCommand(argv) {
238
307
  out(`適用予定 ${applicable.length} ファイル:`);
239
308
  for (const o of applicable)
240
309
  out(` ${opLabel(o)}`);
310
+ printExtras();
241
311
  printAlreadyApplied(plan.outcomes);
242
312
  if (plan.failures.length > 0) {
243
313
  out(` (検証失敗 ${plan.failures.length} 件は --partial によりスキップ)`);
@@ -258,6 +328,11 @@ export async function applyCommand(argv) {
258
328
  for (const o of applicable) {
259
329
  before.set(o.change.path, states.get(o.change.path)?.bytes ?? null);
260
330
  }
331
+ // .vbproj 登録も履歴・undo の対象。changes.md の変更対象でもある .vbproj は元の before を保つ
332
+ for (const [path, w] of vbprojPlan.writes) {
333
+ if (!before.has(path))
334
+ before.set(path, w.before);
335
+ }
261
336
  beginHistory(root, id, changesText, before);
262
337
  const after = new Map();
263
338
  for (const o of applicable) {
@@ -267,10 +342,18 @@ export async function applyCommand(argv) {
267
342
  after.set(o.change.path, null);
268
343
  }
269
344
  else {
270
- writeBytes(abs, o.afterBytes);
271
- after.set(o.change.path, o.afterBytes);
345
+ // 同じ .vbproj への登録があれば登録後の内容 (変更後に登録を重ねたもの) を書く
346
+ const bytes = vbprojPlan.writes.get(o.change.path)?.after ?? o.afterBytes;
347
+ writeBytes(abs, bytes);
348
+ after.set(o.change.path, bytes);
272
349
  }
273
350
  }
351
+ for (const [path, w] of vbprojPlan.writes) {
352
+ if (after.has(path))
353
+ continue;
354
+ writeBytes(join(root, path), w.after);
355
+ after.set(path, w.after);
356
+ }
274
357
  const entries = plan.outcomes.map((o) => {
275
358
  const applied = isApplicable(o);
276
359
  const beforeBytes = states.get(o.change.path)?.bytes ?? null;
@@ -294,14 +377,32 @@ export async function applyCommand(argv) {
294
377
  }
295
378
  if (o.alreadyApplied)
296
379
  entry.alreadyApplied = true;
380
+ const registered = vbprojPlan.writes.get(o.change.path)?.registered;
381
+ if (registered !== undefined)
382
+ entry.registered = registered;
297
383
  return entry;
298
384
  });
385
+ for (const [path, w] of vbprojPlan.writes) {
386
+ if (entries.some((e) => e.path === path))
387
+ continue;
388
+ entries.push({
389
+ path,
390
+ op: "vbproj",
391
+ applied: true,
392
+ blocks: 0,
393
+ appliedBlocks: 0,
394
+ registered: w.registered,
395
+ beforeSha256: w.before !== null ? sha256(w.before) : null,
396
+ afterSha256: sha256(w.after),
397
+ });
398
+ }
299
399
  const manifest = {
300
400
  id,
301
401
  appliedAt: new Date().toISOString(),
302
402
  success: plan.ok,
303
403
  partial: values.partial,
304
404
  source,
405
+ changesFingerprint: fingerprint,
305
406
  files: entries,
306
407
  };
307
408
  finishHistory(root, id, manifest, after);
@@ -314,6 +415,7 @@ export async function applyCommand(argv) {
314
415
  out(`適用しました (履歴 ID: ${id})`);
315
416
  for (const o of applicable)
316
417
  out(` ${opLabel(o)}`);
418
+ printExtras();
317
419
  printAlreadyApplied(plan.outcomes);
318
420
  const appliedNote = buildBlankInsensitiveNote(plan.outcomes);
319
421
  if (appliedNote !== null)
@@ -11,10 +11,11 @@ import { findProjectRoot } from "../infra/root.js";
11
11
  import { err, out } from "../infra/term.js";
12
12
  const CONFIG_TEMPLATE = `{
13
13
  "downloadsDir": null,
14
- "newFile": { "encoding": "utf8", "eol": "lf" },
14
+ "newFile": { "encoding": "auto" },
15
15
  "historyLimit": null,
16
16
  "vscodeCommand": "code",
17
- "clipReportOnFailure": true
17
+ "clipReportOnFailure": true,
18
+ "vbproj": { "register": true }
18
19
  }
19
20
  `;
20
21
  export async function initCommand(argv) {
@@ -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
  }
@@ -22,6 +22,7 @@ import { diffLines, toSideBySideRows } from "../core/diff.js";
22
22
  import { buildReportPage } from "../core/diff-html.js";
23
23
  import { EncodingError, decodeFile } from "../core/encoding.js";
24
24
  import { parseChangesRecovering } from "../core/parser.js";
25
+ import { fixedNewFile } from "../core/new-file-style.js";
25
26
  import { openInBrowser } from "../infra/browser.js";
26
27
  import { loadConfig } from "../infra/config.js";
27
28
  import { monacoVendorDir, startDiffServer } from "../infra/diff-server.js";
@@ -225,7 +226,7 @@ async function runEditServer(root, hdir, id, entries, mine, config, noOpen) {
225
226
  entries: editEntries,
226
227
  root,
227
228
  leftLabel: mine ? "適用直後 (after)" : "適用前 (before)",
228
- fallbackEol: config.newFile.eol,
229
+ fallbackEol: fixedNewFile(config.newFile).eol,
229
230
  });
230
231
  out(`ブラウザ編集サーバーを起動しました: ${handle.url}`);
231
232
  out(" 終了はページ内の「サーバーを終了」ボタンか Ctrl+C (無操作 30 分で自動終了)");
@@ -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
  }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * create した .vb の旧スタイル .vbproj への自動登録 (設計書 §11.1 P1-a) — apply の結線部。
3
+ * 判定・挿入は core/vbproj.ts、探索は infra/vbproj.ts。
4
+ *
5
+ * - 登録の失敗は create 自体の成否に影響しない (レポートに載せるだけ)
6
+ * - 同じ changes.md が .vbproj 自体を変更する場合は、その変更後の内容に対して登録する
7
+ * - 挿入行以外は元バイト列を書き戻す (行単位ドキュメント・§8)
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { basename, dirname, join } from "node:path";
11
+ import { EncodingError, decodeFile, encodeDocument, findUnencodable, } from "../core/encoding.js";
12
+ import { designerParentName, detectSubType, registerCompileItem, toIncludePath, } from "../core/vbproj.js";
13
+ import { isInsideRoot, readFileState } from "../infra/files.js";
14
+ import { findNearestVbproj } from "../infra/vbproj.js";
15
+ const EMPTY = { writes: new Map(), registrations: [], notes: [] };
16
+ function relDirOf(path) {
17
+ const d = dirname(path);
18
+ return d === "." ? "" : d;
19
+ }
20
+ /**
21
+ * 書き込み対象 (applicable) の outcomes から create された .vb を拾い、登録計画を立てる。
22
+ * 書き込みは行わない。
23
+ */
24
+ export function planVbprojRegistrations(root, applicable) {
25
+ const creates = applicable
26
+ .filter((o) => o.change.op === "create" && /\.vb$/i.test(o.change.path))
27
+ // X.vb を X.Designer.vb より先に登録する (VS の並びに合わせる。安定ソート)
28
+ .map((o, i) => ({ o, i, designer: designerParentName(basename(o.change.path)) !== null }))
29
+ .sort((a, b) => Number(a.designer) - Number(b.designer) || a.i - b.i)
30
+ .map((x) => x.o);
31
+ if (creates.length === 0)
32
+ return EMPTY;
33
+ const createdPaths = new Set(applicable.filter((o) => o.change.op === "create").map((o) => o.change.path.toLowerCase()));
34
+ const loaded = new Map();
35
+ const plan = { writes: new Map(), registrations: [], notes: [] };
36
+ const load = (vbproj) => {
37
+ const cached = loaded.get(vbproj);
38
+ if (cached !== undefined)
39
+ return cached;
40
+ const result = loadVbproj(root, vbproj, applicable);
41
+ loaded.set(vbproj, result);
42
+ return result;
43
+ };
44
+ for (const o of creates) {
45
+ const file = o.change.path;
46
+ const lookup = findNearestVbproj(root, relDirOf(file));
47
+ if (lookup.kind === "none") {
48
+ plan.notes.push({ file, message: "vbproj が見つからないため未登録" });
49
+ continue;
50
+ }
51
+ if (lookup.kind === "multiple") {
52
+ plan.notes.push({
53
+ file,
54
+ message: `${lookup.dir} に複数の .vbproj があるため未登録 (${lookup.names.join(", ")})。手動で登録してください`,
55
+ });
56
+ continue;
57
+ }
58
+ const target = load(lookup.path);
59
+ if ("error" in target) {
60
+ plan.notes.push({ file, message: `${lookup.path} に未登録 (${target.error})` });
61
+ continue;
62
+ }
63
+ const include = toIncludePath(relDirOf(lookup.path), file);
64
+ const parent = designerParentName(basename(file));
65
+ const opts = {};
66
+ if (parent !== null) {
67
+ // 親が同じ changes.md で create されるか、既にディスクにあれば DependentUpon を付ける
68
+ const parentPath = relDirOf(file) === "" ? parent : `${relDirOf(file)}/${parent}`;
69
+ if (createdPaths.has(parentPath.toLowerCase()) || existsSync(join(root, parentPath))) {
70
+ opts.dependentUpon = parent;
71
+ }
72
+ }
73
+ else if (o.change.op === "create") {
74
+ const subType = detectSubType(o.change.content);
75
+ if (subType !== undefined)
76
+ opts.subType = subType;
77
+ }
78
+ const r = registerCompileItem(target.lines.map((l) => l.text), include, opts);
79
+ if (r.status === "registered") {
80
+ plan.notes.push({ file, message: `${lookup.path} に登録済み (変更なし)` });
81
+ continue;
82
+ }
83
+ if (r.status === "sdk-style") {
84
+ plan.notes.push({ file, message: `${lookup.path} は SDK スタイルのため登録不要` });
85
+ continue;
86
+ }
87
+ if (r.status === "unsupported") {
88
+ plan.notes.push({
89
+ file,
90
+ message: `${lookup.path} は登録できない形式のため未登録 (${r.reason})。手動で登録してください`,
91
+ });
92
+ continue;
93
+ }
94
+ const bad = findUnencodable(r.inserted.join("\n"), target.doc.encoding);
95
+ if (bad.length > 0) {
96
+ plan.notes.push({
97
+ file,
98
+ message: `${lookup.path} (Shift_JIS) に変換できない文字がパスに含まれるため未登録: ${bad.join(" ")}`,
99
+ });
100
+ continue;
101
+ }
102
+ const inserted = r.inserted.map((text) => ({ text, raw: null, eol: null }));
103
+ target.lines = [...target.lines.slice(0, r.at), ...inserted, ...target.lines.slice(r.at)];
104
+ target.registered.push(file);
105
+ plan.registrations.push({ vbproj: lookup.path, file, include, ...opts });
106
+ }
107
+ for (const [vbproj, t] of loaded) {
108
+ if ("error" in t || t.registered.length === 0)
109
+ continue;
110
+ plan.writes.set(vbproj, {
111
+ before: t.before,
112
+ after: encodeDocument({ ...t.doc, lines: t.lines }),
113
+ registered: t.registered,
114
+ });
115
+ }
116
+ return plan;
117
+ }
118
+ /** .vbproj の現在の内容 (changes.md が同じ .vbproj を変更するならその変更後) を読む */
119
+ function loadVbproj(root, vbproj, applicable) {
120
+ const own = applicable.find((o) => o.change.path === vbproj);
121
+ let bytes;
122
+ if (own !== undefined) {
123
+ if (own.change.op === "delete")
124
+ return { error: "changes.md がこの .vbproj を削除するため" };
125
+ bytes = own.afterBytes;
126
+ }
127
+ else {
128
+ const abs = join(root, vbproj);
129
+ if (!isInsideRoot(root, abs))
130
+ return { error: "プロジェクトルートの外を指しているため" };
131
+ const state = readFileState(abs);
132
+ if (state.symlink)
133
+ return { error: "シンボリックリンクのため" };
134
+ bytes = state.bytes;
135
+ }
136
+ if (bytes === null)
137
+ return { error: "内容を読めないため" };
138
+ try {
139
+ const doc = decodeFile(bytes);
140
+ return { doc, lines: doc.lines, before: bytes, registered: [] };
141
+ }
142
+ catch (e) {
143
+ return { error: e instanceof EncodingError ? e.message : String(e) };
144
+ }
145
+ }
146
+ /** 端末表示 (プレビュー・確認・サマリ共通)。該当がなければ何も出さない */
147
+ export function formatVbprojPlan(plan) {
148
+ const lines = [];
149
+ for (const r of plan.registrations) {
150
+ const extra = [
151
+ r.subType !== undefined ? `SubType: ${r.subType}` : null,
152
+ r.dependentUpon !== undefined ? `DependentUpon: ${r.dependentUpon}` : null,
153
+ ].filter((x) => x !== null);
154
+ lines.push(` vbproj に登録: ${r.vbproj} ← ${r.file}${extra.length > 0 ? ` (${extra.join(", ")})` : ""}`);
155
+ }
156
+ for (const n of plan.notes)
157
+ lines.push(` vbproj: ${n.file} — ${n.message}`);
158
+ return lines;
159
+ }
@@ -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({
@@ -242,9 +249,10 @@ function planFile(change, state, newFile) {
242
249
  }
243
250
  /** 全ファイルをドライラン検証し適用計画を立てる (§4.1 手順 2)。書き込みは行わない */
244
251
  export function planChangeSet(changeSet, states, newFile) {
252
+ const resolve = typeof newFile === "function" ? newFile : () => newFile;
245
253
  const outcomes = changeSet.files.map((change) => {
246
254
  const state = states.get(change.path) ?? { exists: false, symlink: false, bytes: null };
247
- return planFile(change, state, newFile);
255
+ return planFile(change, state, resolve(change));
248
256
  });
249
257
  const failures = outcomes.flatMap((o) => o.failures);
250
258
  return { outcomes, failures, ok: failures.length === 0 };
@@ -70,6 +70,7 @@ section.file{border:1px solid var(--border);border-radius:var(--radius);margin:1
70
70
  .op-create{color:var(--ok);background:color-mix(in srgb,var(--ok) 14%,transparent)}
71
71
  .op-delete{color:var(--err);background:color-mix(in srgb,var(--err) 14%,transparent)}
72
72
  .op-rewrite{color:var(--rewrite);background:color-mix(in srgb,var(--rewrite) 14%,transparent)}
73
+ .op-vbproj{color:var(--accent);background:color-mix(in srgb,var(--accent) 14%,transparent)}
73
74
  .dlabels{display:grid;grid-template-columns:1fr 1fr;font-size:.74rem;color:var(--muted);
74
75
  background:var(--surface);border-bottom:1px solid var(--border)}
75
76
  .dlabels div{padding:.28rem .7rem}
@@ -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)
@@ -0,0 +1,73 @@
1
+ function majority(values, tieBreak) {
2
+ const counts = new Map();
3
+ for (const v of values)
4
+ counts.set(v, (counts.get(v) ?? 0) + 1);
5
+ let best = null;
6
+ let bestCount = -1;
7
+ for (const [v, n] of counts) {
8
+ if (n > bestCount || (n === bestCount && v === tieBreak)) {
9
+ best = v;
10
+ bestCount = n;
11
+ }
12
+ }
13
+ return best ?? tieBreak;
14
+ }
15
+ function extensionOf(path) {
16
+ const name = path.slice(path.lastIndexOf("/") + 1);
17
+ const dot = name.lastIndexOf(".");
18
+ return dot <= 0 ? "" : name.slice(dot).toLowerCase();
19
+ }
20
+ /** auto 以外: 従来どおり明示値 (eol 既定 lf / bom 既定なし) */
21
+ export function fixedNewFile(setting) {
22
+ const encoding = setting.encoding === "auto" ? "utf8" : setting.encoding;
23
+ return { encoding, bom: setting.bom ?? false, eol: setting.eol ?? "lf" };
24
+ }
25
+ function describe(c) {
26
+ return `${c.encoding}${c.encoding === "utf8" ? (c.bom === true ? " (BOM あり)" : " (BOM なし)") : ""} / ${c.eol}`;
27
+ }
28
+ /**
29
+ * 作成先に近いディレクトリから順に手本を探し、最初に見つかったディレクトリの多数決で決める。
30
+ * 手本がなければ拡張子別の既定 (.vb → utf8 + BOM + crlf、それ以外 → utf8 / BOM なし / lf)。
31
+ * .vb で utf8 かつ BOM なしになった場合は BOM を付ける (BOM なし UTF-8 の日本語は vbc で化ける。
32
+ * ASCII のみの Shift_JIS ファイルが UTF-8 判定される既知の限界への対処でもある)。
33
+ */
34
+ export function resolveNewFileStyle(path, setting, groups) {
35
+ if (setting.encoding !== "auto")
36
+ return { config: fixedNewFile(setting), basis: null };
37
+ const ext = extensionOf(path);
38
+ const isVb = ext === ".vb";
39
+ let config;
40
+ let basis;
41
+ const found = groups
42
+ .map((g) => ({ g, samples: g.sameExt.length > 0 ? g.sameExt : g.others, same: g.sameExt.length > 0 }))
43
+ .find((x) => x.samples.length > 0);
44
+ if (found === undefined) {
45
+ config = isVb
46
+ ? { encoding: "utf8", bom: true, eol: "crlf" }
47
+ : { encoding: "utf8", bom: false, eol: "lf" };
48
+ basis = `手本となる既存ファイルなし → ${isVb ? ".vb" : "拡張子"} の既定`;
49
+ }
50
+ else {
51
+ const { g, samples, same } = found;
52
+ const encoding = majority(samples.map((s) => s.encoding), "utf8");
53
+ const utf8Samples = samples.filter((s) => s.encoding === "utf8");
54
+ const bom = encoding === "utf8" ? majority(utf8Samples.map((s) => s.bom), true) : false;
55
+ const eols = samples.map((s) => s.eol).filter((e) => e !== null);
56
+ const eol = eols.length > 0 ? majority(eols, "crlf") : isVb ? "crlf" : "lf";
57
+ config = { encoding, bom, eol };
58
+ const what = same
59
+ ? `${ext === "" ? "拡張子なし" : ext} ${samples.length} ファイル`
60
+ : `テキスト ${samples.length} ファイル (同じ拡張子の手本なし)`;
61
+ basis = `${g.dir === "." ? "プロジェクト直下" : g.dir} の ${what}の多数決`;
62
+ }
63
+ // 明示指定があれば推定より優先する
64
+ if (setting.bom !== undefined)
65
+ config.bom = setting.bom;
66
+ if (setting.eol !== undefined)
67
+ config.eol = setting.eol;
68
+ if (isVb && config.encoding === "utf8" && config.bom !== true && setting.bom === undefined) {
69
+ config.bom = true;
70
+ basis += " → .vb のため BOM を付与 (BOM なし UTF-8 は vbc で日本語が化ける)";
71
+ }
72
+ return { config, basis: `${describe(config)} (${basis})` };
73
+ }
@@ -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)
@@ -0,0 +1,230 @@
1
+ /**
2
+ * 旧スタイル .vbproj への Compile 項目登録 (設計書 §11.1 P1-a)。
3
+ *
4
+ * 旧スタイル .vbproj は <Compile Include> に載っていないファイルをコンパイルしない。
5
+ * AI にこれをやらせるとパックに .vbproj 本体がなく SEARCH が一致しないため、
6
+ * petari が機械的に登録する。実行時依存ゼロの方針により XML パーサは使わず、
7
+ * 行単位の正規表現処理で <ItemGroup> / <Compile Include="..."> を扱う。
8
+ *
9
+ * 純粋関数のみ (ファイル I/O なし)。行配列 in → 挿入位置と挿入行 out。
10
+ * 挿入行以外には触れないため、呼び出し側は既存の行単位ドキュメント (encoding.ts) に
11
+ * 挿入行だけを差し込めばエンコーディング・改行・BOM が保たれる (§8)。
12
+ *
13
+ * 整形が特殊な .vbproj (1 行に複数要素、CDATA、閉じタグ欠落等) は「登録できない形式」として
14
+ * 未登録扱いにし、壊さない。
15
+ */
16
+ /** `<Project Sdk="...">` または Sdk.props の Import があれば SDK スタイル */
17
+ export function isSdkStyleVbproj(lines) {
18
+ const text = lines.join("\n");
19
+ if (/<Project\b[^>]*\bSdk\s*=/i.test(text))
20
+ return true;
21
+ if (/<Import\b[^>]*\bProject\s*=\s*"[^"]*Sdk\.props"/i.test(text))
22
+ return true;
23
+ return /<Sdk\b[^>]*\bName\s*=/i.test(text);
24
+ }
25
+ /** Include 属性値の比較用正規化 (区切り記号と大文字小文字の差は同じファイルとみなす) */
26
+ function normalizeInclude(include) {
27
+ return unescapeXml(include).replace(/\//g, "\\").toLowerCase();
28
+ }
29
+ function escapeXmlAttr(value) {
30
+ return value
31
+ .replace(/&/g, "&amp;")
32
+ .replace(/</g, "&lt;")
33
+ .replace(/>/g, "&gt;")
34
+ .replace(/"/g, "&quot;");
35
+ }
36
+ function unescapeXml(value) {
37
+ return value
38
+ .replace(/&quot;/g, '"')
39
+ .replace(/&apos;/g, "'")
40
+ .replace(/&lt;/g, "<")
41
+ .replace(/&gt;/g, ">")
42
+ .replace(/&amp;/g, "&");
43
+ }
44
+ const RE_ITEMGROUP_OPEN = /^(\s*)<ItemGroup(\s[^<>]*)?>\s*$/i;
45
+ const RE_ITEMGROUP_CLOSE = /^\s*<\/ItemGroup>\s*$/i;
46
+ const RE_COMPILE_SELF = /^(\s*)<Compile\s[^<>]*\/>\s*$/i;
47
+ const RE_COMPILE_OPEN = /^(\s*)<Compile\s[^<>]*[^/]>\s*$/i;
48
+ const RE_COMPILE_CLOSE = /^\s*<\/Compile>\s*$/i;
49
+ const RE_COMPILE_INCLUDE = /<Compile\s[^<>]*\bInclude\s*=\s*"([^"]*)"/i;
50
+ const RE_PROJECT_CLOSE = /^\s*<\/Project>\s*$/i;
51
+ /** 行構造を走査し ItemGroup の一覧を返す。形式が想定外なら文字列で理由を返す */
52
+ function scanItemGroups(lines) {
53
+ const groups = [];
54
+ let current = null;
55
+ let openCompile = false;
56
+ for (let i = 0; i < lines.length; i++) {
57
+ const line = lines[i];
58
+ if (line.includes("<![CDATA["))
59
+ return "CDATA を含む";
60
+ const hasGroupOpen = /<ItemGroup\b/i.test(line);
61
+ const hasGroupClose = /<\/ItemGroup>/i.test(line);
62
+ const hasCompile = /<Compile\b/i.test(line);
63
+ const hasCompileClose = /<\/Compile>/i.test(line);
64
+ if (hasGroupOpen) {
65
+ const m = RE_ITEMGROUP_OPEN.exec(line);
66
+ if (m === null || hasGroupClose || hasCompile)
67
+ return `${i + 1} 行目: <ItemGroup> が単独の行ではない`;
68
+ if (current !== null)
69
+ return `${i + 1} 行目: <ItemGroup> が入れ子になっている`;
70
+ current = {
71
+ open: i,
72
+ close: -1,
73
+ conditional: /\bCondition\s*=/i.test(m[2] ?? ""),
74
+ firstCompile: null,
75
+ indent: m[1] ?? "",
76
+ };
77
+ continue;
78
+ }
79
+ if (hasGroupClose) {
80
+ if (!RE_ITEMGROUP_CLOSE.test(line))
81
+ return `${i + 1} 行目: </ItemGroup> が単独の行ではない`;
82
+ if (current === null)
83
+ return `${i + 1} 行目: 対応する <ItemGroup> がない`;
84
+ if (openCompile)
85
+ return `${i + 1} 行目: <Compile> が閉じられていない`;
86
+ current.close = i;
87
+ groups.push(current);
88
+ current = null;
89
+ continue;
90
+ }
91
+ if (hasCompile) {
92
+ if (current === null)
93
+ return `${i + 1} 行目: <Compile> が <ItemGroup> の外にある`;
94
+ if (openCompile)
95
+ return `${i + 1} 行目: <Compile> が閉じられていない`;
96
+ if (RE_COMPILE_SELF.test(line)) {
97
+ if (current.firstCompile === null)
98
+ current.firstCompile = i;
99
+ continue;
100
+ }
101
+ if (RE_COMPILE_OPEN.test(line) && !hasCompileClose) {
102
+ if (current.firstCompile === null)
103
+ current.firstCompile = i;
104
+ openCompile = true;
105
+ continue;
106
+ }
107
+ return `${i + 1} 行目: <Compile> 要素が 1 行 1 要素になっていない`;
108
+ }
109
+ if (hasCompileClose) {
110
+ if (!openCompile || !RE_COMPILE_CLOSE.test(line))
111
+ return `${i + 1} 行目: </Compile> の位置が不正`;
112
+ openCompile = false;
113
+ }
114
+ }
115
+ if (current !== null)
116
+ return "</ItemGroup> が欠けている";
117
+ return groups;
118
+ }
119
+ function leadingWhitespace(line) {
120
+ return /^\s*/.exec(line)?.[0] ?? "";
121
+ }
122
+ /**
123
+ * 既存の複数行 <Compile> から子要素のインデント増分を推定する。なければ 2 スペース
124
+ * (Visual Studio の既定整形: ` <ItemGroup>` / ` <Compile>` / ` <SubType>`)
125
+ */
126
+ function childIndentDelta(lines) {
127
+ for (let i = 0; i < lines.length - 1; i++) {
128
+ const m = RE_COMPILE_OPEN.exec(lines[i]);
129
+ if (m === null)
130
+ continue;
131
+ const next = lines[i + 1];
132
+ if (RE_COMPILE_CLOSE.test(next))
133
+ continue;
134
+ const child = leadingWhitespace(next);
135
+ const parent = m[1] ?? "";
136
+ if (child.startsWith(parent) && child.length > parent.length)
137
+ return child.slice(parent.length);
138
+ }
139
+ return " ";
140
+ }
141
+ /**
142
+ * relInclude (.vbproj からの相対パス・`\` 区切り) を Compile 項目として登録する位置と行を返す。
143
+ * 判定順: SDK スタイル → 登録済み → 形式検査 → 挿入位置計算。
144
+ */
145
+ export function registerCompileItem(lines, relInclude, opts = {}) {
146
+ if (isSdkStyleVbproj(lines))
147
+ return { status: "sdk-style" };
148
+ const wanted = normalizeInclude(relInclude);
149
+ for (const line of lines) {
150
+ const m = RE_COMPILE_INCLUDE.exec(line);
151
+ if (m !== null && normalizeInclude(m[1] ?? "") === wanted)
152
+ return { status: "registered" };
153
+ }
154
+ if (!lines.some((l) => /<Project\b/i.test(l))) {
155
+ return { status: "unsupported", reason: "<Project> 要素が見つからない" };
156
+ }
157
+ const groups = scanItemGroups(lines);
158
+ if (typeof groups === "string")
159
+ return { status: "unsupported", reason: groups };
160
+ const delta = childIndentDelta(lines);
161
+ const item = (indent) => {
162
+ const include = escapeXmlAttr(relInclude);
163
+ const children = [];
164
+ if (opts.subType !== undefined)
165
+ children.push(`<SubType>${escapeXmlText(opts.subType)}</SubType>`);
166
+ if (opts.dependentUpon !== undefined) {
167
+ children.push(`<DependentUpon>${escapeXmlText(opts.dependentUpon)}</DependentUpon>`);
168
+ }
169
+ if (children.length === 0)
170
+ return [`${indent}<Compile Include="${include}" />`];
171
+ return [
172
+ `${indent}<Compile Include="${include}">`,
173
+ ...children.map((c) => `${indent}${delta}${c}`),
174
+ `${indent}</Compile>`,
175
+ ];
176
+ };
177
+ // Compile 項目を含む最初の (無条件の) ItemGroup の末尾に追加する
178
+ const target = groups.find((g) => g.firstCompile !== null && !g.conditional);
179
+ if (target !== undefined) {
180
+ const indent = leadingWhitespace(lines[target.firstCompile]);
181
+ return { status: "insert", at: target.close, inserted: item(indent) };
182
+ }
183
+ // なければ最後の ItemGroup の後に新しい ItemGroup を作る (ItemGroup が皆無なら </Project> の直前)
184
+ const last = groups[groups.length - 1];
185
+ const projectClose = lines.findIndex((l) => RE_PROJECT_CLOSE.test(l));
186
+ if (last === undefined && projectClose < 0) {
187
+ return { status: "unsupported", reason: "</Project> が見つからない" };
188
+ }
189
+ const groupIndent = last?.indent ?? " ";
190
+ const at = last !== undefined ? last.close + 1 : projectClose;
191
+ return {
192
+ status: "insert",
193
+ at,
194
+ inserted: [`${groupIndent}<ItemGroup>`, ...item(groupIndent + delta), `${groupIndent}</ItemGroup>`],
195
+ };
196
+ }
197
+ function escapeXmlText(value) {
198
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
199
+ }
200
+ /** テスト・表示用: 挿入結果を行配列に反映する */
201
+ export function applyInsertion(lines, result) {
202
+ return [...lines.slice(0, result.at), ...result.inserted, ...lines.slice(result.at)];
203
+ }
204
+ const RE_INHERITS = /^\s*Inherits\s+(?:(?:System\.)?Windows\.Forms\.)?(Form|UserControl)\s*(?:'.*)?$/i;
205
+ /** 新規 .vb の内容から <SubType> を推定する (Inherits Form / UserControl)。該当なしは undefined */
206
+ export function detectSubType(contentLines) {
207
+ for (const line of contentLines) {
208
+ const m = RE_INHERITS.exec(line);
209
+ if (m === null)
210
+ continue;
211
+ const name = m[1].toLowerCase();
212
+ return name === "form" ? "Form" : "UserControl";
213
+ }
214
+ return undefined;
215
+ }
216
+ /** `X.Designer.vb` なら親ファイル名 `X.vb` を返す (大文字小文字は区別しない)。それ以外は null */
217
+ export function designerParentName(fileName) {
218
+ const m = /^(.+)\.designer\.vb$/i.exec(fileName);
219
+ return m === null ? null : `${m[1]}.vb`;
220
+ }
221
+ /** ルート相対パス (/ 区切り) を .vbproj のディレクトリからの相対 Include (\ 区切り) にする */
222
+ export function toIncludePath(vbprojDir, filePath) {
223
+ const base = vbprojDir === "" ? [] : vbprojDir.split("/");
224
+ const target = filePath.split("/");
225
+ let common = 0;
226
+ while (common < base.length && common < target.length && base[common] === target[common])
227
+ common++;
228
+ const ups = base.slice(common).map(() => "..");
229
+ return [...ups, ...target.slice(common)].join("\\");
230
+ }
@@ -7,6 +7,7 @@ export const DEFAULT_CONFIG = {
7
7
  historyLimit: null,
8
8
  vscodeCommand: "code",
9
9
  clipReportOnFailure: true,
10
+ vbproj: { register: true },
10
11
  };
11
12
  /** グローバル設定のパス (§10)。Windows は %APPDATA%、他は XDG (~/.config) */
12
13
  export function globalConfigPath() {
@@ -29,13 +30,21 @@ function readConfigFile(path) {
29
30
  /** JSON 由来の値は型注釈を素通りするため、使う前に実行時検証する */
30
31
  function validateConfig(c) {
31
32
  const enc = c.newFile.encoding;
32
- if (enc !== "utf8" && enc !== "shift_jis") {
33
- throw new Error(`config の newFile.encoding が不正です: ${String(enc)} ("utf8" | "shift_jis")`);
33
+ if (enc !== "utf8" && enc !== "shift_jis" && enc !== "auto") {
34
+ throw new Error(`config の newFile.encoding が不正です: ${String(enc)} ("utf8" | "shift_jis" | "auto")`);
34
35
  }
35
36
  const eol = c.newFile.eol;
36
- if (eol !== "lf" && eol !== "crlf") {
37
+ if (eol !== undefined && eol !== "lf" && eol !== "crlf") {
37
38
  throw new Error(`config の newFile.eol が不正です: ${String(eol)} ("lf" | "crlf")`);
38
39
  }
40
+ const bom = c.newFile.bom;
41
+ if (bom !== undefined && typeof bom !== "boolean") {
42
+ throw new Error(`config の newFile.bom が不正です: ${String(bom)} (true | false)`);
43
+ }
44
+ const register = c.vbproj?.register;
45
+ if (typeof c.vbproj !== "object" || c.vbproj === null || typeof register !== "boolean") {
46
+ throw new Error('config の vbproj が不正です ({ "register": true | false } を指定)');
47
+ }
39
48
  const limit = c.historyLimit;
40
49
  if (limit !== null && (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1)) {
41
50
  throw new Error(`config の historyLimit が不正です: ${String(limit)} (null または 1 以上の整数)`);
@@ -59,10 +68,21 @@ export function loadConfig(root) {
59
68
  ...DEFAULT_CONFIG,
60
69
  ...global,
61
70
  ...project,
62
- newFile: {
63
- ...DEFAULT_CONFIG.newFile,
64
- ...(global.newFile ?? {}),
65
- ...(project.newFile ?? {}),
71
+ newFile: mergeNewFile(global.newFile, project.newFile),
72
+ vbproj: {
73
+ ...DEFAULT_CONFIG.vbproj,
74
+ ...(global.vbproj ?? {}),
75
+ ...(project.vbproj ?? {}),
66
76
  },
67
77
  });
68
78
  }
79
+ /**
80
+ * newFile の合成。encoding "auto" のときは既定の eol: "lf" / bom を混ぜない
81
+ * (混ぜると eol / BOM の推定が効かなくなる)。ユーザーが明示した eol / bom は auto でも残る
82
+ */
83
+ function mergeNewFile(global, project) {
84
+ const user = { ...(global ?? {}), ...(project ?? {}) };
85
+ if (user.encoding === "auto")
86
+ return user;
87
+ return { ...DEFAULT_CONFIG.newFile, ...user };
88
+ }
@@ -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 };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 新規ファイルの形式推定に使う手本の収集 (設計書 §11.2)。ファイル I/O のみ。
3
+ * 多数決と既定の決定は core/new-file-style.ts。
4
+ */
5
+ import { readdirSync, readFileSync, statSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { decodeFile } from "../core/encoding.js";
8
+ /** 1 ディレクトリで読む手本の上限と 1 ファイルの上限 (I/O を抑える) */
9
+ const MAX_FILES_PER_DIR = 100;
10
+ const MAX_FILE_BYTES = 1024 * 1024;
11
+ function extensionOf(name) {
12
+ const dot = name.lastIndexOf(".");
13
+ return dot <= 0 ? "" : name.slice(dot).toLowerCase();
14
+ }
15
+ function sampleOf(absPath) {
16
+ let bytes;
17
+ try {
18
+ if (statSync(absPath).size > MAX_FILE_BYTES)
19
+ return null;
20
+ bytes = new Uint8Array(readFileSync(absPath));
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ if (bytes.length === 0 || bytes.includes(0))
26
+ return null; // 空 / バイナリは手本にしない
27
+ try {
28
+ const doc = decodeFile(bytes);
29
+ return { encoding: doc.encoding, bom: doc.hasBom, eol: doc.eol };
30
+ }
31
+ catch {
32
+ return null; // UTF-8 でも Shift_JIS でもないものは対象外
33
+ }
34
+ }
35
+ function collectDir(absDir, dir, ext) {
36
+ const group = { dir, sameExt: [], others: [] };
37
+ let entries;
38
+ try {
39
+ entries = readdirSync(absDir, { withFileTypes: true });
40
+ }
41
+ catch {
42
+ return group; // 未作成ディレクトリ (create 先)
43
+ }
44
+ const files = entries
45
+ .filter((e) => e.isFile() && !e.name.startsWith("."))
46
+ .map((e) => e.name)
47
+ .sort()
48
+ .slice(0, MAX_FILES_PER_DIR);
49
+ for (const name of files) {
50
+ const s = sampleOf(join(absDir, name));
51
+ if (s === null)
52
+ continue;
53
+ if (extensionOf(name) === ext)
54
+ group.sameExt.push(s);
55
+ else
56
+ group.others.push(s);
57
+ }
58
+ return group;
59
+ }
60
+ /**
61
+ * ルート相対パス path の作成先ディレクトリからルートまで、各ディレクトリの手本を近い順に集める。
62
+ * 手本が見つかったディレクトリで打ち切る (core 側は最初の非空グループを採用する)。
63
+ */
64
+ export function collectStyleSampleGroups(root, path) {
65
+ const ext = extensionOf(path.slice(path.lastIndexOf("/") + 1));
66
+ const groups = [];
67
+ let dir = dirname(path);
68
+ if (dir === ".")
69
+ dir = "";
70
+ for (;;) {
71
+ const group = collectDir(dir === "" ? root : join(root, dir), dir === "" ? "." : dir, ext);
72
+ groups.push(group);
73
+ if (group.sameExt.length > 0 || group.others.length > 0)
74
+ break;
75
+ if (dir === "")
76
+ break;
77
+ const parent = dirname(dir);
78
+ dir = parent === "." ? "" : parent;
79
+ }
80
+ return groups;
81
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 最寄り .vbproj の探索 (設計書 §11.1)。ファイル I/O のみ。判定・挿入は core/vbproj.ts。
3
+ */
4
+ import { readdirSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ function listVbproj(absDir) {
7
+ try {
8
+ return readdirSync(absDir, { withFileTypes: true })
9
+ // 書き込み対象になるため symlink は除外する (Dirent 判定・§9)
10
+ .filter((e) => e.isFile() && /\.vbproj$/i.test(e.name))
11
+ .map((e) => e.name)
12
+ .sort();
13
+ }
14
+ catch {
15
+ return null; // 未作成ディレクトリ (create 先) は手本なしとして上へ辿る
16
+ }
17
+ }
18
+ /**
19
+ * ルート相対ディレクトリ relDir から上へ辿り、プロジェクトルートまでの間で最寄りの .vbproj を探す。
20
+ * ルートの外には出ない。
21
+ */
22
+ export function findNearestVbproj(root, relDir) {
23
+ let dir = relDir === "." ? "" : relDir;
24
+ for (;;) {
25
+ const names = listVbproj(dir === "" ? root : join(root, dir));
26
+ if (names !== null && names.length === 1) {
27
+ return { kind: "found", path: dir === "" ? names[0] : `${dir}/${names[0]}` };
28
+ }
29
+ if (names !== null && names.length > 1)
30
+ return { kind: "multiple", dir: dir === "" ? "." : dir, names };
31
+ if (dir === "")
32
+ return { kind: "none" };
33
+ const parent = dirname(dir);
34
+ dir = parent === "." ? "" : parent;
35
+ }
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "petari",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "petari — paste AI chat patches onto your codebase",
5
5
  "keywords": [
6
6
  "cli",