petari 0.9.0 → 0.9.1
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/dist/assets/protocol.js +4 -2
- package/dist/commands/apply.js +70 -3
- package/dist/commands/init.js +3 -2
- package/dist/commands/show.js +2 -1
- package/dist/commands/vbproj-register.js +159 -0
- package/dist/core/applier.js +2 -1
- package/dist/core/diff-html.js +1 -0
- package/dist/core/new-file-style.js +73 -0
- package/dist/core/vbproj.js +230 -0
- package/dist/infra/config.js +27 -7
- package/dist/infra/new-file-style.js +81 -0
- package/dist/infra/vbproj.js +36 -0
- package/package.json +1 -1
package/dist/assets/protocol.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 制約: slnmix (v0.7.0+) はこのテキストを <instruction>...</instruction> で囲んで
|
|
7
7
|
* 出力へ連結するため、規約文に <instruction> という文字列を含めないこと。
|
|
8
8
|
*/
|
|
9
|
-
export const PROTOCOL_VERSION =
|
|
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 セクションに書く
|
package/dist/commands/apply.js
CHANGED
|
@@ -8,6 +8,7 @@ import { join, resolve } from "node:path";
|
|
|
8
8
|
import { parseArgs } from "node:util";
|
|
9
9
|
import { invalidPathReason, planChangeSet, } from "../core/applier.js";
|
|
10
10
|
import { PRESENCE_STAGE_LABEL } from "../core/matcher.js";
|
|
11
|
+
import { fixedNewFile, resolveNewFileStyle } from "../core/new-file-style.js";
|
|
11
12
|
import { parseChangesRecovering } from "../core/parser.js";
|
|
12
13
|
import { buildBlankInsensitiveNote, buildFailureReport, buildParseErrorReport, } from "../core/report.js";
|
|
13
14
|
import { readClipboard, writeClipboard } from "../infra/clipboard.js";
|
|
@@ -15,10 +16,12 @@ import { loadConfig } from "../infra/config.js";
|
|
|
15
16
|
import { findRecentChangesFiles, resolveDownloadsDir, } from "../infra/downloads.js";
|
|
16
17
|
import { deleteFile, isInsideRoot, readFileState, sha256, writeBytes } from "../infra/files.js";
|
|
17
18
|
import { gitDirtyFiles } from "../infra/git.js";
|
|
19
|
+
import { collectStyleSampleGroups } from "../infra/new-file-style.js";
|
|
18
20
|
import { err, out, outEmphasis } from "../infra/term.js";
|
|
19
21
|
import { beginHistory, createHistoryId, finishHistory, pruneHistory, } from "../infra/history.js";
|
|
20
22
|
import { confirm } from "../infra/prompt.js";
|
|
21
23
|
import { findProjectRoot } from "../infra/root.js";
|
|
24
|
+
import { formatVbprojPlan, planVbprojRegistrations } from "./vbproj-register.js";
|
|
22
25
|
/** このファイルは書き込み対象か (適用済みのみのファイルは書き込み不要なので含めない) */
|
|
23
26
|
function isApplicable(o) {
|
|
24
27
|
if (o.alreadyApplied)
|
|
@@ -78,6 +81,8 @@ export async function applyCommand(argv) {
|
|
|
78
81
|
yes: { type: "boolean", short: "y", default: false },
|
|
79
82
|
clip: { type: "boolean", default: false },
|
|
80
83
|
"clip-report": { type: "boolean", default: false },
|
|
84
|
+
// create した .vb の .vbproj 自動登録を今回だけ無効化 (config vbproj.register の一時上書き)
|
|
85
|
+
"no-vbproj": { type: "boolean", default: false },
|
|
81
86
|
},
|
|
82
87
|
allowPositionals: true,
|
|
83
88
|
});
|
|
@@ -200,7 +205,22 @@ export async function applyCommand(argv) {
|
|
|
200
205
|
}
|
|
201
206
|
states.set(f.path, readFileState(abs));
|
|
202
207
|
}
|
|
203
|
-
|
|
208
|
+
// 新規ファイルの形式: encoding "auto" なら作成先の既存ファイルから推定する (設計書 §11.2)
|
|
209
|
+
const newFileBasis = new Map();
|
|
210
|
+
const resolveNewFile = (change) => {
|
|
211
|
+
if (config.newFile.encoding !== "auto" || change.op !== "create") {
|
|
212
|
+
return fixedNewFile(config.newFile);
|
|
213
|
+
}
|
|
214
|
+
// 不正パス・symlink 経由でルート外を指すパスは手本を探さない (検証段階で失敗する)
|
|
215
|
+
if (invalidPathReason(change.path) !== null || states.get(change.path)?.escapesRoot === true) {
|
|
216
|
+
return fixedNewFile(config.newFile);
|
|
217
|
+
}
|
|
218
|
+
const r = resolveNewFileStyle(change.path, config.newFile, collectStyleSampleGroups(root, change.path));
|
|
219
|
+
if (r.basis !== null)
|
|
220
|
+
newFileBasis.set(change.path, r.basis);
|
|
221
|
+
return r.config;
|
|
222
|
+
};
|
|
223
|
+
const plan = planChangeSet(changeSet, states, resolveNewFile);
|
|
204
224
|
// 3. 失敗があれば何も書き込まずレポート (§4.1, §7)
|
|
205
225
|
if (!plan.ok && !values.partial) {
|
|
206
226
|
err(`petari: 検証に失敗しました (${plan.failures.length} 件)。何も書き込んでいません。\n`);
|
|
@@ -208,6 +228,20 @@ export async function applyCommand(argv) {
|
|
|
208
228
|
return 1;
|
|
209
229
|
}
|
|
210
230
|
const applicable = plan.outcomes.filter(isApplicable);
|
|
231
|
+
// create した .vb の旧スタイル .vbproj への登録計画 (設計書 §11.1)。失敗しても create は成功扱い
|
|
232
|
+
const vbprojEnabled = config.vbproj.register && !values["no-vbproj"];
|
|
233
|
+
const vbprojPlan = vbprojEnabled
|
|
234
|
+
? planVbprojRegistrations(root, applicable)
|
|
235
|
+
: { writes: new Map(), registrations: [], notes: [] };
|
|
236
|
+
const printExtras = () => {
|
|
237
|
+
for (const o of applicable) {
|
|
238
|
+
const basis = newFileBasis.get(o.change.path);
|
|
239
|
+
if (basis !== undefined)
|
|
240
|
+
out(` 新規ファイルの形式: ${o.change.path} → ${basis}`);
|
|
241
|
+
}
|
|
242
|
+
for (const line of formatVbprojPlan(vbprojPlan))
|
|
243
|
+
out(line);
|
|
244
|
+
};
|
|
211
245
|
if (applicable.length === 0) {
|
|
212
246
|
// 冪等性: 全変更が適用済みなら成功として終了 (書き込み・履歴なし)
|
|
213
247
|
if (plan.ok && plan.outcomes.some((o) => o.alreadyApplied)) {
|
|
@@ -223,6 +257,7 @@ export async function applyCommand(argv) {
|
|
|
223
257
|
if (values["dry-run"]) {
|
|
224
258
|
out(`dry-run: 適用予定 ${applicable.length} ファイル (書き込みなし)`);
|
|
225
259
|
printPreview(applicable);
|
|
260
|
+
printExtras();
|
|
226
261
|
printAlreadyApplied(plan.outcomes);
|
|
227
262
|
const dryRunNote = buildBlankInsensitiveNote(plan.outcomes);
|
|
228
263
|
if (dryRunNote !== null)
|
|
@@ -238,6 +273,7 @@ export async function applyCommand(argv) {
|
|
|
238
273
|
out(`適用予定 ${applicable.length} ファイル:`);
|
|
239
274
|
for (const o of applicable)
|
|
240
275
|
out(` ${opLabel(o)}`);
|
|
276
|
+
printExtras();
|
|
241
277
|
printAlreadyApplied(plan.outcomes);
|
|
242
278
|
if (plan.failures.length > 0) {
|
|
243
279
|
out(` (検証失敗 ${plan.failures.length} 件は --partial によりスキップ)`);
|
|
@@ -258,6 +294,11 @@ export async function applyCommand(argv) {
|
|
|
258
294
|
for (const o of applicable) {
|
|
259
295
|
before.set(o.change.path, states.get(o.change.path)?.bytes ?? null);
|
|
260
296
|
}
|
|
297
|
+
// .vbproj 登録も履歴・undo の対象。changes.md の変更対象でもある .vbproj は元の before を保つ
|
|
298
|
+
for (const [path, w] of vbprojPlan.writes) {
|
|
299
|
+
if (!before.has(path))
|
|
300
|
+
before.set(path, w.before);
|
|
301
|
+
}
|
|
261
302
|
beginHistory(root, id, changesText, before);
|
|
262
303
|
const after = new Map();
|
|
263
304
|
for (const o of applicable) {
|
|
@@ -267,10 +308,18 @@ export async function applyCommand(argv) {
|
|
|
267
308
|
after.set(o.change.path, null);
|
|
268
309
|
}
|
|
269
310
|
else {
|
|
270
|
-
|
|
271
|
-
|
|
311
|
+
// 同じ .vbproj への登録があれば登録後の内容 (変更後に登録を重ねたもの) を書く
|
|
312
|
+
const bytes = vbprojPlan.writes.get(o.change.path)?.after ?? o.afterBytes;
|
|
313
|
+
writeBytes(abs, bytes);
|
|
314
|
+
after.set(o.change.path, bytes);
|
|
272
315
|
}
|
|
273
316
|
}
|
|
317
|
+
for (const [path, w] of vbprojPlan.writes) {
|
|
318
|
+
if (after.has(path))
|
|
319
|
+
continue;
|
|
320
|
+
writeBytes(join(root, path), w.after);
|
|
321
|
+
after.set(path, w.after);
|
|
322
|
+
}
|
|
274
323
|
const entries = plan.outcomes.map((o) => {
|
|
275
324
|
const applied = isApplicable(o);
|
|
276
325
|
const beforeBytes = states.get(o.change.path)?.bytes ?? null;
|
|
@@ -294,8 +343,25 @@ export async function applyCommand(argv) {
|
|
|
294
343
|
}
|
|
295
344
|
if (o.alreadyApplied)
|
|
296
345
|
entry.alreadyApplied = true;
|
|
346
|
+
const registered = vbprojPlan.writes.get(o.change.path)?.registered;
|
|
347
|
+
if (registered !== undefined)
|
|
348
|
+
entry.registered = registered;
|
|
297
349
|
return entry;
|
|
298
350
|
});
|
|
351
|
+
for (const [path, w] of vbprojPlan.writes) {
|
|
352
|
+
if (entries.some((e) => e.path === path))
|
|
353
|
+
continue;
|
|
354
|
+
entries.push({
|
|
355
|
+
path,
|
|
356
|
+
op: "vbproj",
|
|
357
|
+
applied: true,
|
|
358
|
+
blocks: 0,
|
|
359
|
+
appliedBlocks: 0,
|
|
360
|
+
registered: w.registered,
|
|
361
|
+
beforeSha256: w.before !== null ? sha256(w.before) : null,
|
|
362
|
+
afterSha256: sha256(w.after),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
299
365
|
const manifest = {
|
|
300
366
|
id,
|
|
301
367
|
appliedAt: new Date().toISOString(),
|
|
@@ -314,6 +380,7 @@ export async function applyCommand(argv) {
|
|
|
314
380
|
out(`適用しました (履歴 ID: ${id})`);
|
|
315
381
|
for (const o of applicable)
|
|
316
382
|
out(` ${opLabel(o)}`);
|
|
383
|
+
printExtras();
|
|
317
384
|
printAlreadyApplied(plan.outcomes);
|
|
318
385
|
const appliedNote = buildBlankInsensitiveNote(plan.outcomes);
|
|
319
386
|
if (appliedNote !== null)
|
package/dist/commands/init.js
CHANGED
|
@@ -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": "
|
|
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) {
|
package/dist/commands/show.js
CHANGED
|
@@ -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 分で自動終了)");
|
|
@@ -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
|
+
}
|
package/dist/core/applier.js
CHANGED
|
@@ -242,9 +242,10 @@ function planFile(change, state, newFile) {
|
|
|
242
242
|
}
|
|
243
243
|
/** 全ファイルをドライラン検証し適用計画を立てる (§4.1 手順 2)。書き込みは行わない */
|
|
244
244
|
export function planChangeSet(changeSet, states, newFile) {
|
|
245
|
+
const resolve = typeof newFile === "function" ? newFile : () => newFile;
|
|
245
246
|
const outcomes = changeSet.files.map((change) => {
|
|
246
247
|
const state = states.get(change.path) ?? { exists: false, symlink: false, bytes: null };
|
|
247
|
-
return planFile(change, state,
|
|
248
|
+
return planFile(change, state, resolve(change));
|
|
248
249
|
});
|
|
249
250
|
const failures = outcomes.flatMap((o) => o.failures);
|
|
250
251
|
return { outcomes, failures, ok: failures.length === 0 };
|
package/dist/core/diff-html.js
CHANGED
|
@@ -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,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
|
+
}
|
|
@@ -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, "&")
|
|
32
|
+
.replace(/</g, "<")
|
|
33
|
+
.replace(/>/g, ">")
|
|
34
|
+
.replace(/"/g, """);
|
|
35
|
+
}
|
|
36
|
+
function unescapeXml(value) {
|
|
37
|
+
return value
|
|
38
|
+
.replace(/"/g, '"')
|
|
39
|
+
.replace(/'/g, "'")
|
|
40
|
+
.replace(/</g, "<")
|
|
41
|
+
.replace(/>/g, ">")
|
|
42
|
+
.replace(/&/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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|
package/dist/infra/config.js
CHANGED
|
@@ -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
|
-
|
|
64
|
-
...
|
|
65
|
-
...(
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|