petari 0.1.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/LICENSE +21 -0
- package/README.md +113 -0
- package/dist/assets/protocol.js +73 -0
- package/dist/cli.js +79 -0
- package/dist/commands/apply.js +271 -0
- package/dist/commands/init.js +101 -0
- package/dist/commands/list.js +73 -0
- package/dist/commands/protocol.js +9 -0
- package/dist/commands/show.js +162 -0
- package/dist/commands/undo.js +107 -0
- package/dist/core/applier.js +150 -0
- package/dist/core/encoding.js +135 -0
- package/dist/core/matcher.js +97 -0
- package/dist/core/parser.js +249 -0
- package/dist/core/report.js +40 -0
- package/dist/core/sjis-table.js +6 -0
- package/dist/core/sjis.js +82 -0
- package/dist/infra/clipboard.js +62 -0
- package/dist/infra/config.js +64 -0
- package/dist/infra/downloads.js +83 -0
- package/dist/infra/files.js +59 -0
- package/dist/infra/git.js +16 -0
- package/dist/infra/history.js +72 -0
- package/dist/infra/prompt.js +12 -0
- package/dist/infra/root.js +20 -0
- package/dist/infra/term.js +14 -0
- package/dist/types.js +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* petari list — 履歴の一覧 (§4.4)。
|
|
3
|
+
* ID・日時・概要 (CHANGES 先頭行)・対象ファイル数・成否と、履歴全体のサイズを表示する。
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { historyRoot, listHistoryIds, readManifest } from "../infra/history.js";
|
|
9
|
+
import { findProjectRoot } from "../infra/root.js";
|
|
10
|
+
import { out } from "../infra/term.js";
|
|
11
|
+
function dirSize(dir) {
|
|
12
|
+
let total = 0;
|
|
13
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
14
|
+
const p = join(dir, e.name);
|
|
15
|
+
if (e.isDirectory())
|
|
16
|
+
total += dirSize(p);
|
|
17
|
+
else
|
|
18
|
+
total += statSync(p).size;
|
|
19
|
+
}
|
|
20
|
+
return total;
|
|
21
|
+
}
|
|
22
|
+
function formatSize(bytes) {
|
|
23
|
+
if (bytes < 1024)
|
|
24
|
+
return `${bytes} B`;
|
|
25
|
+
if (bytes < 1024 * 1024)
|
|
26
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
27
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
28
|
+
}
|
|
29
|
+
/** changes.md の CHANGES 概要の先頭行 (## CHANGES の次の非空行) */
|
|
30
|
+
function summaryLine(historyDir) {
|
|
31
|
+
const p = join(historyDir, "changes.md");
|
|
32
|
+
if (!existsSync(p))
|
|
33
|
+
return "";
|
|
34
|
+
const lines = readFileSync(p, "utf8").split(/\r?\n/);
|
|
35
|
+
const start = lines.findIndex((l) => l.trimEnd() === "## CHANGES");
|
|
36
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
37
|
+
const line = lines[i].trim();
|
|
38
|
+
if (line.startsWith("### FILE:"))
|
|
39
|
+
break;
|
|
40
|
+
if (line !== "")
|
|
41
|
+
return line;
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
export async function listCommand(argv) {
|
|
46
|
+
const { values } = parseArgs({
|
|
47
|
+
args: argv,
|
|
48
|
+
options: { root: { type: "string" } },
|
|
49
|
+
});
|
|
50
|
+
const root = findProjectRoot(process.cwd(), values.root);
|
|
51
|
+
const ids = listHistoryIds(root);
|
|
52
|
+
if (ids.length === 0) {
|
|
53
|
+
out("履歴がありません。");
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
let total = 0;
|
|
57
|
+
for (const id of [...ids].reverse()) {
|
|
58
|
+
const dir = join(historyRoot(root), id);
|
|
59
|
+
total += dirSize(dir);
|
|
60
|
+
const m = readManifest(root, id);
|
|
61
|
+
if (m === null) {
|
|
62
|
+
out(`${id} (manifest なし)`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const appliedCount = m.files.filter((f) => f.applied).length;
|
|
66
|
+
const status = m.success ? "成功" : m.partial ? "部分適用" : "失敗";
|
|
67
|
+
const summary = summaryLine(dir);
|
|
68
|
+
out(`${id} ${status} ${appliedCount} ファイル ${summary}`);
|
|
69
|
+
}
|
|
70
|
+
out("");
|
|
71
|
+
out(`履歴 ${ids.length} 件 / 合計 ${formatSize(total)} (.petari/history)`);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* petari show <ID> — VS Code のネイティブ差分ビューで履歴を表示 (§4.3)。
|
|
3
|
+
* - 既定: before vs after (AI の変更そのもの)
|
|
4
|
+
* - --mine: after vs 現在のファイル = 適用後の手修正分だけを可視化
|
|
5
|
+
* - --changes: 保存済み changes.md の CHANGES セクション (概要・Mermaid) を表示
|
|
6
|
+
* 複数ファイルの履歴はファイル一覧を提示して選択させる (--file で直接指定も可)。
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { isAbsolute, join } from "node:path";
|
|
12
|
+
import { parseArgs } from "node:util";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
import { invalidPathReason } from "../core/applier.js";
|
|
15
|
+
import { parseChanges } from "../core/parser.js";
|
|
16
|
+
import { loadConfig } from "../infra/config.js";
|
|
17
|
+
import { historyRoot, listHistoryIds, readManifest } from "../infra/history.js";
|
|
18
|
+
import { createInterface } from "node:readline/promises";
|
|
19
|
+
import { findProjectRoot } from "../infra/root.js";
|
|
20
|
+
import { err, out } from "../infra/term.js";
|
|
21
|
+
const execFileP = promisify(execFile);
|
|
22
|
+
/**
|
|
23
|
+
* vscodeCommand の形式制限 (セキュリティレビュー指摘 3)。
|
|
24
|
+
* プロジェクト設定はリポジトリに同梱され得るため、リポジトリ内のスクリプトを
|
|
25
|
+
* 指せる相対パス (./evil.sh 等) を拒否する。PATH 上のコマンド名か絶対パスのみ許可。
|
|
26
|
+
*/
|
|
27
|
+
export function invalidVscodeCommandReason(cmd) {
|
|
28
|
+
if (cmd.trim() === "")
|
|
29
|
+
return "vscodeCommand が空です";
|
|
30
|
+
if (cmd.startsWith("\\\\") || cmd.startsWith("//")) {
|
|
31
|
+
return "vscodeCommand に UNC パスは使えません";
|
|
32
|
+
}
|
|
33
|
+
if ((cmd.includes("/") || cmd.includes("\\")) && !isAbsolute(cmd)) {
|
|
34
|
+
return "vscodeCommand に相対パスは使えません (コマンド名または絶対パスを指定してください)";
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
let emptyFileCache = null;
|
|
39
|
+
function emptyFile() {
|
|
40
|
+
if (emptyFileCache === null) {
|
|
41
|
+
emptyFileCache = join(mkdtempSync(join(tmpdir(), "petari-")), "empty");
|
|
42
|
+
writeFileSync(emptyFileCache, "");
|
|
43
|
+
}
|
|
44
|
+
return emptyFileCache;
|
|
45
|
+
}
|
|
46
|
+
/** 差分対象のファイルパス組を解決する (create/delete の欠損側は空ファイル) */
|
|
47
|
+
export function resolveDiffPair(root, historyDir, entry, mine) {
|
|
48
|
+
const before = join(historyDir, "before", entry.path);
|
|
49
|
+
const after = join(historyDir, "after", entry.path);
|
|
50
|
+
const current = join(root, entry.path);
|
|
51
|
+
if (mine) {
|
|
52
|
+
if (entry.op === "delete")
|
|
53
|
+
return { error: `${entry.path} は delete のため --mine の比較対象がありません` };
|
|
54
|
+
if (!existsSync(current))
|
|
55
|
+
return { error: `${entry.path} は現在存在しません (undo 済み?)` };
|
|
56
|
+
return { left: after, right: current, title: `${entry.path} (適用直後 → 現在 = 手修正分)` };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
left: existsSync(before) ? before : emptyFile(),
|
|
60
|
+
right: existsSync(after) ? after : emptyFile(),
|
|
61
|
+
title: `${entry.path} (before → after)`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async function pickEntry(entries) {
|
|
65
|
+
out("対象ファイルを選択してください:");
|
|
66
|
+
entries.forEach((e, i) => out(` ${i + 1}. ${e.op.padEnd(7)} ${e.path}`));
|
|
67
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
68
|
+
try {
|
|
69
|
+
const answer = (await rl.question(`番号 [1-${entries.length}]: `)).trim();
|
|
70
|
+
const n = Number.parseInt(answer, 10);
|
|
71
|
+
if (Number.isNaN(n) || n < 1 || n > entries.length)
|
|
72
|
+
return null;
|
|
73
|
+
return entries[n - 1];
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
rl.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function showCommand(argv) {
|
|
80
|
+
const { values, positionals } = parseArgs({
|
|
81
|
+
args: argv,
|
|
82
|
+
options: {
|
|
83
|
+
root: { type: "string" },
|
|
84
|
+
mine: { type: "boolean", default: false },
|
|
85
|
+
changes: { type: "boolean", default: false },
|
|
86
|
+
file: { type: "string" },
|
|
87
|
+
},
|
|
88
|
+
allowPositionals: true,
|
|
89
|
+
});
|
|
90
|
+
const root = findProjectRoot(process.cwd(), values.root);
|
|
91
|
+
const config = loadConfig(root);
|
|
92
|
+
const ids = listHistoryIds(root);
|
|
93
|
+
if (ids.length === 0) {
|
|
94
|
+
err("petari: 履歴がありません");
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
const id = positionals[0] ?? ids[ids.length - 1];
|
|
98
|
+
const manifest = readManifest(root, id);
|
|
99
|
+
if (manifest === null) {
|
|
100
|
+
err(`petari: 履歴 ${id} が見つかりません (petari list で確認してください)`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
const hdir = join(historyRoot(root), id);
|
|
104
|
+
// --changes: CHANGES セクション (概要・影響一覧・Mermaid) の表示 (§13-4)
|
|
105
|
+
if (values.changes) {
|
|
106
|
+
const text = readFileSync(join(hdir, "changes.md"), "utf8");
|
|
107
|
+
const { changeSet } = parseChanges(text);
|
|
108
|
+
out(changeSet.header !== "" ? changeSet.header : "(CHANGES セクションがありません)");
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
const applied = manifest.files.filter((f) => f.applied);
|
|
112
|
+
if (applied.length === 0) {
|
|
113
|
+
err(`petari: 履歴 ${id} に適用済みファイルがありません`);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
let entry;
|
|
117
|
+
if (values.file !== undefined) {
|
|
118
|
+
const found = applied.find((e) => e.path === values.file);
|
|
119
|
+
if (found === undefined) {
|
|
120
|
+
err(`petari: ${values.file} は履歴 ${id} にありません`);
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
entry = found;
|
|
124
|
+
}
|
|
125
|
+
else if (applied.length === 1) {
|
|
126
|
+
entry = applied[0];
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const picked = await pickEntry(applied);
|
|
130
|
+
if (picked === null) {
|
|
131
|
+
err("petari: 中止しました");
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
entry = picked;
|
|
135
|
+
}
|
|
136
|
+
// manifest は改ざんされ得るため、パスと起動コマンドをここでも検証する (§9)
|
|
137
|
+
const pathReason = invalidPathReason(entry.path);
|
|
138
|
+
if (pathReason !== null) {
|
|
139
|
+
err(`petari: manifest のパスが不正です: ${entry.path} (${pathReason})`);
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
const cmdReason = invalidVscodeCommandReason(config.vscodeCommand);
|
|
143
|
+
if (cmdReason !== null) {
|
|
144
|
+
err(`petari: ${cmdReason}: ${config.vscodeCommand}`);
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
const pair = resolveDiffPair(root, hdir, entry, values.mine);
|
|
148
|
+
if ("error" in pair) {
|
|
149
|
+
err(`petari: ${pair.error}`);
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
out(`${config.vscodeCommand} --diff で開きます: ${pair.title}`);
|
|
153
|
+
try {
|
|
154
|
+
await execFileP(config.vscodeCommand, ["--diff", pair.left, pair.right]);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
err(`petari: ${config.vscodeCommand} の起動に失敗しました: ${e instanceof Error ? e.message : String(e)}`);
|
|
158
|
+
err(" .petari/config.json の vscodeCommand を確認してください");
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* petari undo [ID] — 履歴の巻き戻し (§4.2)。
|
|
3
|
+
* manifest の操作種別に基づき逆適用する:
|
|
4
|
+
* replace/rewrite → before を書き戻し / create → ファイル削除 / delete → before から復元
|
|
5
|
+
* 適用後に手修正されている場合 (現状ハッシュ ≠ after ハッシュ) は警告して確認する。
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import { invalidPathReason } from "../core/applier.js";
|
|
11
|
+
import { loadConfig } from "../infra/config.js";
|
|
12
|
+
import { deleteFile, isInsideRoot, readFileState, sha256, writeBytes } from "../infra/files.js";
|
|
13
|
+
import { historyRoot, listHistoryIds, readManifest } from "../infra/history.js";
|
|
14
|
+
import { confirm } from "../infra/prompt.js";
|
|
15
|
+
import { findProjectRoot } from "../infra/root.js";
|
|
16
|
+
import { err, out } from "../infra/term.js";
|
|
17
|
+
export async function undoCommand(argv) {
|
|
18
|
+
const { values, positionals } = parseArgs({
|
|
19
|
+
args: argv,
|
|
20
|
+
options: {
|
|
21
|
+
root: { type: "string" },
|
|
22
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
23
|
+
},
|
|
24
|
+
allowPositionals: true,
|
|
25
|
+
});
|
|
26
|
+
const root = findProjectRoot(process.cwd(), values.root);
|
|
27
|
+
loadConfig(root); // 設定エラーの早期検出のみ
|
|
28
|
+
const ids = listHistoryIds(root);
|
|
29
|
+
if (ids.length === 0) {
|
|
30
|
+
err("petari: 履歴がありません");
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
const id = positionals[0] ?? ids[ids.length - 1];
|
|
34
|
+
const manifest = readManifest(root, id);
|
|
35
|
+
if (manifest === null) {
|
|
36
|
+
err(`petari: 履歴 ${id} が見つかりません (petari list で確認してください)`);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
const hdir = join(historyRoot(root), id);
|
|
40
|
+
const applied = manifest.files.filter((f) => f.applied);
|
|
41
|
+
if (applied.length === 0) {
|
|
42
|
+
err(`petari: 履歴 ${id} に適用済みファイルがありません`);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
// 逆適用の材料を全件検証してから書き込む (undo 自体も all-or-nothing)
|
|
46
|
+
const actions = [];
|
|
47
|
+
for (const entry of applied) {
|
|
48
|
+
// manifest は改ざんされ得るため、apply と同じパス検証をここでも行う (§9)
|
|
49
|
+
const pathReason = invalidPathReason(entry.path);
|
|
50
|
+
if (pathReason !== null) {
|
|
51
|
+
err(`petari: manifest のパスが不正です: ${entry.path} (${pathReason})`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
const abs = join(root, entry.path);
|
|
55
|
+
if (!isInsideRoot(root, abs)) {
|
|
56
|
+
err(`petari: manifest のパスがプロジェクトルートの外を指しています: ${entry.path}`);
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
const current = readFileState(abs);
|
|
60
|
+
if (current.symlink) {
|
|
61
|
+
err(`petari: シンボリックリンクは巻き戻し対象外です: ${entry.path}`);
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
let restoreBytes = null;
|
|
65
|
+
if (entry.op !== "create") {
|
|
66
|
+
const beforePath = join(hdir, "before", entry.path);
|
|
67
|
+
if (!existsSync(beforePath)) {
|
|
68
|
+
err(`petari: 履歴の before ファイルが欠損しています: ${beforePath}`);
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
restoreBytes = new Uint8Array(readFileSync(beforePath));
|
|
72
|
+
}
|
|
73
|
+
const modified = entry.op === "delete"
|
|
74
|
+
? current.exists // delete したはずのファイルが存在する
|
|
75
|
+
: !current.exists ||
|
|
76
|
+
current.bytes === null ||
|
|
77
|
+
sha256(current.bytes) !== entry.afterSha256;
|
|
78
|
+
actions.push({ entry, restoreBytes, modified });
|
|
79
|
+
}
|
|
80
|
+
const modified = actions.filter((a) => a.modified);
|
|
81
|
+
out(`履歴 ${id} を巻き戻します (${applied.length} ファイル)`);
|
|
82
|
+
if (modified.length > 0) {
|
|
83
|
+
out("警告: 適用後に手修正されたファイルがあります。巻き戻すと手修正が失われます:");
|
|
84
|
+
for (const a of modified)
|
|
85
|
+
out(` ${a.entry.path}`);
|
|
86
|
+
}
|
|
87
|
+
if (!values.yes) {
|
|
88
|
+
if (!(await confirm(modified.length > 0 ? "手修正が失われますが戻しますか?" : "巻き戻しますか?"))) {
|
|
89
|
+
out("中止しました。");
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const a of actions) {
|
|
94
|
+
const abs = join(root, a.entry.path);
|
|
95
|
+
if (a.entry.op === "create") {
|
|
96
|
+
if (existsSync(abs))
|
|
97
|
+
deleteFile(abs);
|
|
98
|
+
out(` 削除: ${a.entry.path} (create の巻き戻し)`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
writeBytes(abs, a.restoreBytes);
|
|
102
|
+
out(` 復元: ${a.entry.path}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
out(`巻き戻しました (履歴 ID: ${id})`);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { EncodingError, decodeFile, encodeDocument, findUnencodable, makeDocument, } from "./encoding.js";
|
|
2
|
+
import { STAGE_LABEL, matchBlock } from "./matcher.js";
|
|
3
|
+
/** Windows の予約デバイス名 (拡張子付きも不可) */
|
|
4
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..+)?$/i;
|
|
5
|
+
/** §9: プロジェクトルート相対のみ。絶対パス・`..`・`~`・Windows 特殊パスを拒否 */
|
|
6
|
+
export function invalidPathReason(path) {
|
|
7
|
+
if (path.trim() === "")
|
|
8
|
+
return "パスが空です";
|
|
9
|
+
if (path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:/.test(path)) {
|
|
10
|
+
return "絶対パスは使えません (プロジェクトルート相対で指定してください)";
|
|
11
|
+
}
|
|
12
|
+
if (path.includes(":"))
|
|
13
|
+
return "':' を含むパスは使えません (Windows の代替データストリーム対策)";
|
|
14
|
+
const segments = path.split("/");
|
|
15
|
+
if (segments.includes(".."))
|
|
16
|
+
return "'..' を含むパスは使えません";
|
|
17
|
+
if (path.startsWith("~"))
|
|
18
|
+
return "'~' で始まるパスは使えません";
|
|
19
|
+
// Windows は末尾のドット/空白を無視して解釈するため、除去してから予約名判定する
|
|
20
|
+
if (segments.some((s) => WINDOWS_RESERVED.test(s.replace(/[. ]+$/, "")))) {
|
|
21
|
+
return "Windows の予約デバイス名 (CON, NUL, COM1 等) を含むパスは使えません";
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
function fileFailure(change, kind, message) {
|
|
26
|
+
return {
|
|
27
|
+
change,
|
|
28
|
+
failures: [{ path: change.path, kind, message }],
|
|
29
|
+
afterBytes: null,
|
|
30
|
+
appliedBlocks: [],
|
|
31
|
+
totalBlocks: change.op === "replace" ? change.blocks.length : 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function planReplace(change, state, fallbackEol) {
|
|
35
|
+
let doc;
|
|
36
|
+
try {
|
|
37
|
+
doc = decodeFile(state.bytes);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
return fileFailure(change, "undecodable", e instanceof EncodingError ? e.message : String(e));
|
|
41
|
+
}
|
|
42
|
+
const failures = [];
|
|
43
|
+
const applied = [];
|
|
44
|
+
let docLines = doc.lines;
|
|
45
|
+
let textLines = docLines.map((l) => l.text);
|
|
46
|
+
for (const block of change.blocks) {
|
|
47
|
+
const m = matchBlock(textLines, block);
|
|
48
|
+
if (!m.ok) {
|
|
49
|
+
const message = m.reason === "ambiguous"
|
|
50
|
+
? `ブロック ${block.index}: SEARCH が ${m.count} 箇所にマッチしました (${STAGE_LABEL[m.stage]})。一意に特定できる範囲を含めてください`
|
|
51
|
+
: `ブロック ${block.index}: SEARCH が現在のファイル内容に見つかりません`;
|
|
52
|
+
failures.push({ path: change.path, kind: m.reason === "ambiguous" ? "block-ambiguous" : "block-not-found", message, block });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const replacement = m.replacement;
|
|
56
|
+
const bad = findUnencodable(replacement.join("\n"), doc.encoding);
|
|
57
|
+
if (bad.length > 0) {
|
|
58
|
+
failures.push({
|
|
59
|
+
path: change.path,
|
|
60
|
+
kind: "unencodable",
|
|
61
|
+
message: `ブロック ${block.index}: REPLACE 側に Shift_JIS へ変換できない文字が含まれています: ${bad.join(" ")}`,
|
|
62
|
+
block,
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
applied.push({
|
|
67
|
+
block,
|
|
68
|
+
stage: m.stage,
|
|
69
|
+
start: m.start,
|
|
70
|
+
removed: textLines.slice(m.start, m.end),
|
|
71
|
+
inserted: replacement,
|
|
72
|
+
});
|
|
73
|
+
const newDocLines = replacement.map((text) => ({ text, raw: null, eol: null }));
|
|
74
|
+
docLines = [...docLines.slice(0, m.start), ...newDocLines, ...docLines.slice(m.end)];
|
|
75
|
+
textLines = [...textLines.slice(0, m.start), ...replacement, ...textLines.slice(m.end)];
|
|
76
|
+
}
|
|
77
|
+
const afterBytes = applied.length > 0 ? encodeDocument({ ...doc, lines: docLines }, fallbackEol) : null;
|
|
78
|
+
return {
|
|
79
|
+
change,
|
|
80
|
+
failures,
|
|
81
|
+
afterBytes,
|
|
82
|
+
appliedBlocks: applied,
|
|
83
|
+
totalBlocks: change.blocks.length,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function planFile(change, state, newFile) {
|
|
87
|
+
const pathReason = invalidPathReason(change.path);
|
|
88
|
+
if (pathReason !== null)
|
|
89
|
+
return fileFailure(change, "path-invalid", pathReason);
|
|
90
|
+
if (state.escapesRoot === true) {
|
|
91
|
+
return fileFailure(change, "path-invalid", "シンボリックリンクを経由してプロジェクトルートの外を指しています (§9)");
|
|
92
|
+
}
|
|
93
|
+
if (state.symlink) {
|
|
94
|
+
return fileFailure(change, "symlink", "シンボリックリンクは書き換え対象外です (§9)");
|
|
95
|
+
}
|
|
96
|
+
switch (change.op) {
|
|
97
|
+
case "create": {
|
|
98
|
+
if (state.exists) {
|
|
99
|
+
return fileFailure(change, "target-exists", "create 指定ですがファイルが既に存在します (全文置き換えなら rewrite を使用)");
|
|
100
|
+
}
|
|
101
|
+
const bad = findUnencodable(change.content.join("\n"), newFile.encoding);
|
|
102
|
+
if (bad.length > 0) {
|
|
103
|
+
return fileFailure(change, "unencodable", `新規ファイルの既定エンコーディング (${newFile.encoding}) に変換できない文字: ${bad.join(" ")}`);
|
|
104
|
+
}
|
|
105
|
+
const doc = makeDocument(change.content, newFile);
|
|
106
|
+
return { change, failures: [], afterBytes: encodeDocument(doc), appliedBlocks: [], totalBlocks: 0 };
|
|
107
|
+
}
|
|
108
|
+
case "rewrite": {
|
|
109
|
+
if (!state.exists) {
|
|
110
|
+
return fileFailure(change, "target-missing", "rewrite 指定ですがファイルが存在しません (新規作成なら create を使用)");
|
|
111
|
+
}
|
|
112
|
+
let doc;
|
|
113
|
+
try {
|
|
114
|
+
doc = decodeFile(state.bytes);
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
return fileFailure(change, "undecodable", e instanceof EncodingError ? e.message : String(e));
|
|
118
|
+
}
|
|
119
|
+
const bad = findUnencodable(change.content.join("\n"), doc.encoding);
|
|
120
|
+
if (bad.length > 0) {
|
|
121
|
+
return fileFailure(change, "unencodable", `Shift_JIS へ変換できない文字が含まれています: ${bad.join(" ")}`);
|
|
122
|
+
}
|
|
123
|
+
// 既存のエンコーディング・BOM・支配的 EOL・末尾改行の有無を維持して全文を差し替える (§8)
|
|
124
|
+
const lines = change.content.map((text) => ({ text, raw: null, eol: null }));
|
|
125
|
+
const afterBytes = encodeDocument({ ...doc, lines });
|
|
126
|
+
return { change, failures: [], afterBytes, appliedBlocks: [], totalBlocks: 0 };
|
|
127
|
+
}
|
|
128
|
+
case "delete": {
|
|
129
|
+
if (!state.exists) {
|
|
130
|
+
return fileFailure(change, "target-missing", "delete 指定ですがファイルが存在しません");
|
|
131
|
+
}
|
|
132
|
+
return { change, failures: [], afterBytes: null, appliedBlocks: [], totalBlocks: 0 };
|
|
133
|
+
}
|
|
134
|
+
case "replace": {
|
|
135
|
+
if (!state.exists) {
|
|
136
|
+
return fileFailure(change, "target-missing", "replace 指定ですがファイルが存在しません");
|
|
137
|
+
}
|
|
138
|
+
return planReplace(change, state, newFile.eol);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** 全ファイルをドライラン検証し適用計画を立てる (§4.1 手順 2)。書き込みは行わない */
|
|
143
|
+
export function planChangeSet(changeSet, states, newFile) {
|
|
144
|
+
const outcomes = changeSet.files.map((change) => {
|
|
145
|
+
const state = states.get(change.path) ?? { exists: false, symlink: false, bytes: null };
|
|
146
|
+
return planFile(change, state, newFile);
|
|
147
|
+
});
|
|
148
|
+
const failures = outcomes.flatMap((o) => o.failures);
|
|
149
|
+
return { outcomes, failures, ok: failures.length === 0 };
|
|
150
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* エンコーディングと改行の保全 (§8 — 最重要要件)。
|
|
3
|
+
*
|
|
4
|
+
* 方針: ファイルを「行単位のドキュメント」として扱い、各行の元バイト列 (raw) と
|
|
5
|
+
* 実際の改行コードを保持する。書き込み時、変更していない行は raw をそのまま出力する。
|
|
6
|
+
* これにより Shift_JIS の重複マッピング (NEC/IBM 拡張等) があっても、
|
|
7
|
+
* 触っていない行のバイト列は原理的に変わらない。再エンコードは変更行のみ。
|
|
8
|
+
*/
|
|
9
|
+
import { sjisEncode } from "./sjis.js";
|
|
10
|
+
export class EncodingError extends Error {
|
|
11
|
+
}
|
|
12
|
+
const BOM = Uint8Array.of(0xef, 0xbb, 0xbf);
|
|
13
|
+
const LF = Uint8Array.of(0x0a);
|
|
14
|
+
const CRLF = Uint8Array.of(0x0d, 0x0a);
|
|
15
|
+
function tryDecode(bytes, label) {
|
|
16
|
+
try {
|
|
17
|
+
new TextDecoder(label, { fatal: true }).decode(bytes);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* バイト列からエンコーディング・BOM・改行を検出し、行ドキュメントに分解する (§8)。
|
|
26
|
+
* 判定順: UTF-16 BOM → 非対応エラー / UTF-8 BOM / 厳密 UTF-8 / Shift_JIS / エラー。
|
|
27
|
+
* (対象環境のエンコーディングは UTF-8 / Shift_JIS の 2 択という前提。ASCII のみの
|
|
28
|
+
* ファイルは UTF-8 と判定されるが、どちらで書いても同一バイトになるため実害はない)
|
|
29
|
+
*/
|
|
30
|
+
export function decodeFile(bytes) {
|
|
31
|
+
if (bytes.length >= 2) {
|
|
32
|
+
const [b0, b1] = [bytes[0], bytes[1]];
|
|
33
|
+
if ((b0 === 0xff && b1 === 0xfe) || (b0 === 0xfe && b1 === 0xff)) {
|
|
34
|
+
throw new EncodingError("UTF-16 のファイルは対象外です (UTF-8 か Shift_JIS に変換してから使用してください)");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
let hasBom = false;
|
|
38
|
+
let body = bytes;
|
|
39
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
40
|
+
hasBom = true;
|
|
41
|
+
body = bytes.subarray(3);
|
|
42
|
+
}
|
|
43
|
+
let encoding;
|
|
44
|
+
if (hasBom || tryDecode(body, "utf-8")) {
|
|
45
|
+
if (hasBom && !tryDecode(body, "utf-8")) {
|
|
46
|
+
throw new EncodingError("UTF-8 BOM 付きですが本文が UTF-8 として不正です");
|
|
47
|
+
}
|
|
48
|
+
encoding = "utf8";
|
|
49
|
+
}
|
|
50
|
+
else if (tryDecode(body, "shift_jis")) {
|
|
51
|
+
encoding = "shift_jis";
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
throw new EncodingError("エンコーディングを判定できません (UTF-8 でも Shift_JIS でもありません)");
|
|
55
|
+
}
|
|
56
|
+
// 行分割はバイトレベルで行う。UTF-8 の継続バイトも Shift_JIS の 2 バイト目
|
|
57
|
+
// (0x40-0xFC) も 0x0A/0x0D と衝突しないため安全。
|
|
58
|
+
const decoder = new TextDecoder(encoding === "utf8" ? "utf-8" : "shift_jis", { fatal: true });
|
|
59
|
+
const lines = [];
|
|
60
|
+
let lineStart = 0;
|
|
61
|
+
let crlfCount = 0;
|
|
62
|
+
let lfCount = 0;
|
|
63
|
+
for (let i = 0; i < body.length; i++) {
|
|
64
|
+
if (body[i] === 0x0a) {
|
|
65
|
+
const isCrlf = i > lineStart && body[i - 1] === 0x0d;
|
|
66
|
+
const raw = body.subarray(lineStart, isCrlf ? i - 1 : i);
|
|
67
|
+
lines.push({ text: decoder.decode(raw), raw, eol: isCrlf ? "crlf" : "lf" });
|
|
68
|
+
if (isCrlf)
|
|
69
|
+
crlfCount++;
|
|
70
|
+
else
|
|
71
|
+
lfCount++;
|
|
72
|
+
lineStart = i + 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
let trailingNewline = lines.length > 0;
|
|
76
|
+
if (lineStart < body.length) {
|
|
77
|
+
const raw = body.subarray(lineStart);
|
|
78
|
+
lines.push({ text: decoder.decode(raw), raw, eol: null });
|
|
79
|
+
trailingNewline = false;
|
|
80
|
+
}
|
|
81
|
+
const eol = crlfCount === 0 && lfCount === 0 ? null : crlfCount >= lfCount ? "crlf" : "lf";
|
|
82
|
+
return { encoding, hasBom, eol, trailingNewline, lines };
|
|
83
|
+
}
|
|
84
|
+
function encodeText(text, encoding) {
|
|
85
|
+
if (encoding === "utf8")
|
|
86
|
+
return new TextEncoder().encode(text);
|
|
87
|
+
const { bytes, unencodable } = sjisEncode(text);
|
|
88
|
+
if (unencodable.length > 0) {
|
|
89
|
+
throw new EncodingError(`Shift_JIS に変換できない文字が含まれています: ${unencodable.join(" ")}`);
|
|
90
|
+
}
|
|
91
|
+
return bytes;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 行ドキュメントをバイト列に書き戻す。raw を持つ行はバイト列をそのまま出力し、
|
|
95
|
+
* raw のない行 (新規・置換) のみエンコードする。
|
|
96
|
+
* fallbackEol は改行を 1 つも持たないファイルに行を追加した場合にのみ使われる。
|
|
97
|
+
*/
|
|
98
|
+
export function encodeDocument(doc, fallbackEol = "lf") {
|
|
99
|
+
const chunks = [];
|
|
100
|
+
if (doc.hasBom)
|
|
101
|
+
chunks.push(BOM);
|
|
102
|
+
const defaultEol = doc.eol ?? fallbackEol;
|
|
103
|
+
for (let i = 0; i < doc.lines.length; i++) {
|
|
104
|
+
const line = doc.lines[i];
|
|
105
|
+
chunks.push(line.raw ?? encodeText(line.text, doc.encoding));
|
|
106
|
+
const isLast = i === doc.lines.length - 1;
|
|
107
|
+
if (!isLast || doc.trailingNewline) {
|
|
108
|
+
chunks.push((line.eol ?? defaultEol) === "crlf" ? CRLF : LF);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
112
|
+
const out = new Uint8Array(total);
|
|
113
|
+
let off = 0;
|
|
114
|
+
for (const c of chunks) {
|
|
115
|
+
out.set(c, off);
|
|
116
|
+
off += c.length;
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
/** 新規ファイル (create) 用のドキュメントを作る。既定は UTF-8 / LF / 末尾改行あり (§8) */
|
|
121
|
+
export function makeDocument(textLines, opts) {
|
|
122
|
+
return {
|
|
123
|
+
encoding: opts.encoding,
|
|
124
|
+
hasBom: opts.bom ?? false,
|
|
125
|
+
eol: opts.eol,
|
|
126
|
+
trailingNewline: opts.trailingNewline ?? textLines.length > 0,
|
|
127
|
+
lines: textLines.map((text) => ({ text, raw: null, eol: null })),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** テキストが対象エンコーディングで表現できない文字を返す (適用前検証・§8) */
|
|
131
|
+
export function findUnencodable(text, encoding) {
|
|
132
|
+
if (encoding === "utf8")
|
|
133
|
+
return [];
|
|
134
|
+
return sjisEncode(text).unencodable;
|
|
135
|
+
}
|