aizuchi 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 +149 -0
- package/dist/bin/aizuchi.js +546 -0
- package/dist/server/app.js +817 -0
- package/dist/server/board.js +248 -0
- package/dist/server/events.js +24 -0
- package/dist/server/lib/config-paths.js +91 -0
- package/dist/server/lib/docs.js +154 -0
- package/dist/server/lib/files.js +208 -0
- package/dist/server/lib/fs-safe.js +428 -0
- package/dist/server/lib/git.js +100 -0
- package/dist/server/lib/id.js +54 -0
- package/dist/server/lib/pinpoint.js +225 -0
- package/dist/server/lib/proposal.js +93 -0
- package/dist/server/lib/task-file.js +132 -0
- package/dist/server/lib/task-ops.js +206 -0
- package/dist/server/lib/time.js +24 -0
- package/dist/server/overview.js +119 -0
- package/dist/server/static.js +71 -0
- package/dist/server/watcher.js +38 -0
- package/dist/shared/critical-path.js +128 -0
- package/dist/shared/diff.js +46 -0
- package/dist/shared/digest.js +44 -0
- package/dist/shared/milestone.js +29 -0
- package/dist/shared/related-docs.js +77 -0
- package/dist/shared/schema.js +376 -0
- package/dist/shared/stats.js +167 -0
- package/dist/web/assets/index-BLN_aXBe.css +1 -0
- package/dist/web/assets/index-BS_n8d9B.js +82 -0
- package/dist/web/index.html +13 -0
- package/package.json +73 -0
- package/templates/claude-rules.md +85 -0
- package/templates/config.json +28 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
/**
|
|
4
|
+
* git 履歴連携 (S-1・read-only)。コミットメッセージの `TASK-xxx` をパースし、タスクに紐づくコミットを返す。
|
|
5
|
+
*
|
|
6
|
+
* セキュリティ設計 (中核):
|
|
7
|
+
* - **`execFile('git', [args], {cwd})` を使う。シェルを経由しない** (`exec` / `shell:true` 禁止 =
|
|
8
|
+
* インジェクション面ゼロ)。引数は固定配列で、ユーザー由来の値 (タスク ID) は引数として渡すが、
|
|
9
|
+
* その前に厳格バリデーション (TASK_ID_RE) を通す。不一致なら git を起動せず [] を返す。
|
|
10
|
+
* - `--` 区切りでオプション終端を明示。固定フォーマット `--format=%H%x00%s%x00%aI` (NUL 区切りで解析安全)。
|
|
11
|
+
* - read-only サブコマンドのみ (`git log --all --grep` / `git rev-parse`)。
|
|
12
|
+
* - N-1 (外部通信なし): git はローカル subprocess のみ。fetch / pull は実行しない (log は外部に出ない)。
|
|
13
|
+
* - graceful degradation: 非 git リポ / git 未インストール (ENOENT) / タイムアウトは静かに無効化して [] を返す。
|
|
14
|
+
* - N-8: エラー時に cwd やフルパスをレスポンス / ログに出さない (catch して [] に倒す)。
|
|
15
|
+
*/
|
|
16
|
+
const execFileAsync = promisify(execFile);
|
|
17
|
+
/** git の実行タイムアウト (巨大リポでの暴走防止) */
|
|
18
|
+
const GIT_TIMEOUT_MS = 3000;
|
|
19
|
+
/** 検索するコミットの上限 (詳細パネル表示用。過大なリポでの応答肥大を防ぐ) */
|
|
20
|
+
const MAX_COMMITS = 50;
|
|
21
|
+
/** NUL (U+0000) 区切り文字。LEARN#13: invisible 制御文字は literal ではなく \uXXXX escape で書く */
|
|
22
|
+
const NUL = '\u0000';
|
|
23
|
+
/**
|
|
24
|
+
* タスク ID の許可形式 (config.idPrefix + 数字)。`^[A-Za-z0-9_]+-\d+$` のみ許可する。
|
|
25
|
+
* 仕様の `^[A-Za-z0-9]+-\d+$` に config.idPrefix が許す `_` を加えた字種 (idPrefix は `^[A-Za-z0-9_]+$`)。
|
|
26
|
+
* いずれもシェルメタ文字を含まないため execFile の引数として安全 (二重防御)。
|
|
27
|
+
* これにマッチしない値 (`; rm -rf` 等のインジェクション文字列を含む) は git を起動せず [] を返す。
|
|
28
|
+
* execFile はそもそも引数をシェル解釈しないが、`--grep` の値として渡る前に二重で安全側に倒す。
|
|
29
|
+
*/
|
|
30
|
+
const TASK_ID_RE = /^[A-Za-z0-9_]+-\d+$/;
|
|
31
|
+
export function isValidTaskId(id) {
|
|
32
|
+
return TASK_ID_RE.test(id);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 指定ディレクトリが git の作業ツリー内かを判定する (read-only)。
|
|
36
|
+
* 非 git / git 未インストール (ENOENT) / タイムアウトは false (機能を静かに無効化する)。
|
|
37
|
+
*/
|
|
38
|
+
async function isGitRepo(cwd) {
|
|
39
|
+
try {
|
|
40
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
41
|
+
cwd,
|
|
42
|
+
timeout: GIT_TIMEOUT_MS,
|
|
43
|
+
});
|
|
44
|
+
return stdout.trim() === 'true';
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* タスク ID を含むコミットを検索して返す (read-only)。
|
|
52
|
+
* - ID が TASK_ID_RE に不一致なら git を起動せず [] (インジェクション防御)。
|
|
53
|
+
* - 非 git / git 未インストール / タイムアウト / 該当なしは [] (graceful degradation)。
|
|
54
|
+
* - subject に ID 文字列が部分一致するコミットを新しい順に最大 MAX_COMMITS 件返す。
|
|
55
|
+
*/
|
|
56
|
+
export async function findCommitsForTask(cwd, taskId) {
|
|
57
|
+
if (!isValidTaskId(taskId))
|
|
58
|
+
return [];
|
|
59
|
+
if (!(await isGitRepo(cwd)))
|
|
60
|
+
return [];
|
|
61
|
+
try {
|
|
62
|
+
const { stdout } = await execFileAsync('git', [
|
|
63
|
+
'log',
|
|
64
|
+
'--all',
|
|
65
|
+
`--max-count=${MAX_COMMITS}`,
|
|
66
|
+
// NUL 区切りの固定フォーマット: hash NUL subject NUL authorDate(ISO)。レコード間は改行
|
|
67
|
+
'--format=%H%x00%s%x00%aI',
|
|
68
|
+
// taskId を --grep の値として渡す。--fixed-strings で正規表現メタ文字をリテラル扱いにする
|
|
69
|
+
// (TASK_ID_RE 通過後なのでメタ文字は入らないが二重防御)。大文字小文字は区別する。
|
|
70
|
+
'--fixed-strings',
|
|
71
|
+
`--grep=${taskId}`,
|
|
72
|
+
// `--` でオプション終端を明示 (以降に pathspec が来ないことを示し、ID が誤ってパスとして解釈されない)
|
|
73
|
+
'--',
|
|
74
|
+
], { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1024 * 1024 });
|
|
75
|
+
return parseCommitLog(stdout);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// git の異常終了 (壊れたリポ等) / タイムアウトは静かに [] (N-8: 詳細を露出しない)
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `git log --format=%H%x00%s%x00%aI` の出力を CommitRef[] に解析する。
|
|
84
|
+
* 各レコードは改行区切り、フィールドは NUL (U+0000) 区切り。hash / authorDate が空の行はスキップする。
|
|
85
|
+
*/
|
|
86
|
+
export function parseCommitLog(stdout) {
|
|
87
|
+
const commits = [];
|
|
88
|
+
for (const line of stdout.split('\n')) {
|
|
89
|
+
if (line === '')
|
|
90
|
+
continue;
|
|
91
|
+
const parts = line.split(NUL);
|
|
92
|
+
if (parts.length < 3)
|
|
93
|
+
continue;
|
|
94
|
+
const [hash, subject, authorDate] = parts;
|
|
95
|
+
if (hash === '' || authorDate === '')
|
|
96
|
+
continue;
|
|
97
|
+
commits.push({ hash, subject, authorDate });
|
|
98
|
+
}
|
|
99
|
+
return commits;
|
|
100
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { collectKanbanFilesNoSymlink } from './fs-safe.js';
|
|
4
|
+
/**
|
|
5
|
+
* ID 採番 (要件 §5.5): `tasks/` と `archive/**` を走査し、`<idPrefix>-<番号>.md` の
|
|
6
|
+
* 最大番号 + 1 をゼロ詰め 3 桁で返す (桁あふれ時は自然に 4 桁以上になる)。
|
|
7
|
+
* ID = ファイル名のため、frontmatter ではなくファイル名を走査する。
|
|
8
|
+
*
|
|
9
|
+
* R17-F (M1): 走査を realpath containment 化する。旧 `fs.readdir({recursive:true})` は中間 dir symlink
|
|
10
|
+
* (`.kanban/tasks/sub → 外部dir`) を辿り、外部 dir の `<prefix>-99999.md` 等で採番番号を汚染できた
|
|
11
|
+
* (内容漏洩なし・exclusive create で一意性は保持・弱い DoS)。collectKanbanFilesNoSymlink で
|
|
12
|
+
* symlink 非追従 + 信頼境界内に限定して走査する (FIX-D と同型・LEARN#6 boundary drift 完結)。
|
|
13
|
+
* 信頼境界は kanbanDir 自身の realpath (呼出側が `<projectRoot>/.kanban` を渡す既知のディレクトリ)。
|
|
14
|
+
*/
|
|
15
|
+
function escapeRegExp(text) {
|
|
16
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
17
|
+
}
|
|
18
|
+
export async function allocateNextId(kanbanDir, idPrefix) {
|
|
19
|
+
const pattern = new RegExp(`^${escapeRegExp(idPrefix)}-(\\d+)\\.md$`);
|
|
20
|
+
let max = 0;
|
|
21
|
+
// kanbanDir 自身の realpath を信頼境界にする (kanbanDir が symlink でも実体内に正規化)。
|
|
22
|
+
// kanbanDir 不在なら走査対象ゼロ → 初番に倒す
|
|
23
|
+
let kanbanReal;
|
|
24
|
+
try {
|
|
25
|
+
kanbanReal = await fs.realpath(kanbanDir);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return `${idPrefix}-${String(max + 1).padStart(3, '0')}`;
|
|
29
|
+
}
|
|
30
|
+
for (const sub of ['tasks', 'archive']) {
|
|
31
|
+
const dir = path.join(kanbanReal, sub);
|
|
32
|
+
// collectKanbanFilesNoSymlink は不在 dir / symlink dir / 中間 dir symlink を [] (skip) に倒すため try/catch 不要
|
|
33
|
+
const files = await collectKanbanFilesNoSymlink(dir, kanbanReal, (abs) => pattern.test(path.basename(abs)));
|
|
34
|
+
for (const abs of files) {
|
|
35
|
+
const matched = path.basename(abs).match(pattern);
|
|
36
|
+
if (matched)
|
|
37
|
+
max = Math.max(max, Number.parseInt(matched[1], 10));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return `${idPrefix}-${String(max + 1).padStart(3, '0')}`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* プロセス内 Promise キュー。
|
|
44
|
+
* POST /api/tasks の「走査 → 採番 → 書込」を直列化し、同時起票の ID 重複を防ぐ。
|
|
45
|
+
* 前のジョブが失敗しても後続は実行する。
|
|
46
|
+
*/
|
|
47
|
+
export function createSerialQueue() {
|
|
48
|
+
let tail = Promise.resolve();
|
|
49
|
+
return function enqueue(job) {
|
|
50
|
+
const next = tail.then(job, job);
|
|
51
|
+
tail = next.then(() => undefined, () => undefined);
|
|
52
|
+
return next;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import yaml from 'js-yaml';
|
|
2
|
+
/** 値を 1 行の YAML スカラーとして出力する (serializeTaskFile と同じ JSON_SCHEMA 規則) */
|
|
3
|
+
function dumpScalar(value) {
|
|
4
|
+
if (typeof value === 'string' && /[\r\n]/.test(value)) {
|
|
5
|
+
// 改行入り文字列は YAML 互換の JSON 文字列にして 1 行に収める (行置換モデルを守る)
|
|
6
|
+
return JSON.stringify(value);
|
|
7
|
+
}
|
|
8
|
+
return yaml.dump(value, { schema: yaml.JSON_SCHEMA, lineWidth: -1 }).replace(/\n$/, '');
|
|
9
|
+
}
|
|
10
|
+
/** 行置換を適用した新しいファイル全文を返す。frontmatter が無いファイルは例外 */
|
|
11
|
+
export function applyPinpointPatch(raw, patch) {
|
|
12
|
+
// 先頭 BOM は保全しつつ、判定・置換は BOM を除いたテキストで行う (parseTaskFile と挙動を揃える)
|
|
13
|
+
const bom = raw.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
14
|
+
const text = bom ? raw.slice(1) : raw;
|
|
15
|
+
const lines = text.split('\n');
|
|
16
|
+
if ((lines[0] ?? '').replace(/\r$/, '') !== '---') {
|
|
17
|
+
throw new Error('frontmatter ブロックが見つからない (ファイル先頭が --- ではない)');
|
|
18
|
+
}
|
|
19
|
+
let closeIndex = -1;
|
|
20
|
+
for (let i = 1; i < lines.length; i++) {
|
|
21
|
+
if ((lines[i] ?? '').replace(/\r$/, '') === '---') {
|
|
22
|
+
closeIndex = i;
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (closeIndex === -1) {
|
|
27
|
+
throw new Error('frontmatter の閉じ --- が見つからない');
|
|
28
|
+
}
|
|
29
|
+
const replaceLine = (key, value) => {
|
|
30
|
+
const rendered = `${key}: ${dumpScalar(value)}`;
|
|
31
|
+
for (let i = 1; i < closeIndex; i++) {
|
|
32
|
+
const line = (lines[i] ?? '').replace(/\r$/, '');
|
|
33
|
+
// トップレベルのキー行のみ一致する (ブロックスカラー内の行はインデントされるため一致しない)
|
|
34
|
+
if (line === `${key}:` || line.startsWith(`${key}: `) || line.startsWith(`${key}:\t`)) {
|
|
35
|
+
lines[i] = (lines[i] ?? '').endsWith('\r') ? `${rendered}\r` : rendered;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// 行が無い場合 (AI が省略可能フィールドを書かずに起票した等) は閉じ --- の直前に追記する。
|
|
40
|
+
// EOL は閉じ --- 行に合わせる (CRLF ファイルに LF 行を混在させない)
|
|
41
|
+
const closeHasCr = (lines[closeIndex] ?? '').endsWith('\r');
|
|
42
|
+
lines.splice(closeIndex, 0, closeHasCr ? `${rendered}\r` : rendered);
|
|
43
|
+
closeIndex += 1;
|
|
44
|
+
};
|
|
45
|
+
replaceLine('status', patch.status);
|
|
46
|
+
replaceLine('updated', patch.updated);
|
|
47
|
+
if ('blocked_type' in patch)
|
|
48
|
+
replaceLine('blocked_type', patch.blocked_type ?? null);
|
|
49
|
+
if ('blocked_reason' in patch)
|
|
50
|
+
replaceLine('blocked_reason', patch.blocked_reason ?? null);
|
|
51
|
+
return bom + lines.join('\n');
|
|
52
|
+
}
|
|
53
|
+
/** frontmatter ブロック (先頭 --- 〜 次の ---) の行範囲を返す。無ければ null。BOM 込みで扱う */
|
|
54
|
+
function frontmatterRange(text) {
|
|
55
|
+
const lines = text.split('\n');
|
|
56
|
+
if ((lines[0] ?? '').replace(/\r$/, '') !== '---')
|
|
57
|
+
return null;
|
|
58
|
+
for (let i = 1; i < lines.length; i++) {
|
|
59
|
+
if ((lines[i] ?? '').replace(/\r$/, '') === '---')
|
|
60
|
+
return { lines, close: i };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
/** タスク ID トークンの正規表現を組み立てる (単語境界。depends_on の flow / 通常表記の両方に効く) */
|
|
65
|
+
function idTokenRe(id) {
|
|
66
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
67
|
+
return new RegExp(`(?<![A-Za-z0-9_-])${escaped}(?![A-Za-z0-9_-])`, 'g');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 重複 ID リネーム支援 (F-6-5)。frontmatter の `id:` 行を newId に置き換える (本文は保全)。
|
|
71
|
+
* frontmatter が無い / id 行が無い場合は元の raw をそのまま返す (防御)。
|
|
72
|
+
*/
|
|
73
|
+
export function renameTaskId(raw, newId) {
|
|
74
|
+
const bom = raw.startsWith('') ? '' : '';
|
|
75
|
+
const text = bom ? raw.slice(1) : raw;
|
|
76
|
+
const fm = frontmatterRange(text);
|
|
77
|
+
if (!fm)
|
|
78
|
+
return raw;
|
|
79
|
+
const { lines, close } = fm;
|
|
80
|
+
for (let i = 1; i < close; i++) {
|
|
81
|
+
const line = (lines[i] ?? '').replace(/\r$/, '');
|
|
82
|
+
if (line === 'id:' || line.startsWith('id: ') || line.startsWith('id:\t')) {
|
|
83
|
+
const rendered = `id: ${newId}`;
|
|
84
|
+
lines[i] = (lines[i] ?? '').endsWith('\r') ? `${rendered}\r` : rendered;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return bom + lines.join('\n');
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 他タスクの frontmatter `depends_on` の oldId を newId に置き換える (F-6-5 の連鎖更新)。
|
|
92
|
+
* flow 形式 (`depends_on: [TASK-001]`) と block 形式 (`depends_on:` 改行 ` - TASK-001`) の
|
|
93
|
+
* 両方に対応する (R8-3)。本文中の同名トークンや他フィールドには触れない。
|
|
94
|
+
* depends_on に oldId が含まれなければ raw をそのまま返す (変更なし)。
|
|
95
|
+
*/
|
|
96
|
+
export function replaceDependsOnId(raw, oldId, newId) {
|
|
97
|
+
const bom = raw.startsWith('') ? '' : '';
|
|
98
|
+
const text = bom ? raw.slice(1) : raw;
|
|
99
|
+
const fm = frontmatterRange(text);
|
|
100
|
+
if (!fm)
|
|
101
|
+
return raw;
|
|
102
|
+
const { lines, close } = fm;
|
|
103
|
+
const tokenRe = idTokenRe(oldId);
|
|
104
|
+
let changed = false;
|
|
105
|
+
const replaceInLine = (i) => {
|
|
106
|
+
const cr = (lines[i] ?? '').endsWith('\r');
|
|
107
|
+
const line = (lines[i] ?? '').replace(/\r$/, '');
|
|
108
|
+
const replaced = line.replace(tokenRe, newId);
|
|
109
|
+
if (replaced !== line) {
|
|
110
|
+
lines[i] = cr ? `${replaced}\r` : replaced;
|
|
111
|
+
changed = true;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
for (let i = 1; i < close; i++) {
|
|
115
|
+
const line = (lines[i] ?? '').replace(/\r$/, '');
|
|
116
|
+
if (line !== 'depends_on:' && !line.startsWith('depends_on: ') && !line.startsWith('depends_on:\t'))
|
|
117
|
+
continue;
|
|
118
|
+
// flow 形式 (inline 値) の置換
|
|
119
|
+
replaceInLine(i);
|
|
120
|
+
// block 形式: depends_on: の後に inline 値が無い (改行のみ) 場合、続くインデント行 (` - ...`) を走査
|
|
121
|
+
const inlineValue = line.replace(/^depends_on:\s*/, '');
|
|
122
|
+
if (inlineValue === '') {
|
|
123
|
+
for (let j = i + 1; j < close; j++) {
|
|
124
|
+
const item = (lines[j] ?? '').replace(/\r$/, '');
|
|
125
|
+
// インデントされた list 項目 (`- ...`) のみ block の続き。非インデント = ブロック終了
|
|
126
|
+
if (/^\s+-\s/.test(item)) {
|
|
127
|
+
replaceInLine(j);
|
|
128
|
+
}
|
|
129
|
+
else if (item.trim() === '') {
|
|
130
|
+
continue; // 空行はスキップして続ける
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
break; // 次のトップレベルキー = depends_on ブロック終了
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return changed ? bom + lines.join('\n') : raw;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* verify-after-patch ガード (PM Round 1 #2/#3/#6 への包括対処)。
|
|
142
|
+
*
|
|
143
|
+
* `applyPinpointPatch` の行置換は「parse が受理する YAML 表記」の部分集合しか正しく扱えない
|
|
144
|
+
* (block scalar / コロン前スペース / quoted key / インデント継続行 等)。これらでは
|
|
145
|
+
* 「parse は通るのに行置換が値を壊す or 取りこぼす」非対称が起きうる。
|
|
146
|
+
*
|
|
147
|
+
* そこで patch 前後を再 parse して比較し、
|
|
148
|
+
* 「patch が触れるべき frontmatter フィールドだけが・意図どおりの値に変わり、本文 (body) は不変」
|
|
149
|
+
* であることを検証する。不一致なら理由文字列を返す (呼び出し側で書込を拒否する)。問題なければ null。
|
|
150
|
+
*
|
|
151
|
+
* 本文比較 (PM Round 2 R2-2): 現状の applyPinpointPatch は frontmatter 行しか触らないため body は
|
|
152
|
+
* 不変のはずだが、将来パッチ対象が増えたときの body silent 破壊をこのガードで止める。
|
|
153
|
+
*
|
|
154
|
+
* @param before patch 前の parse 結果 (parseTaskFile(raw))
|
|
155
|
+
* @param after patch 後の parse 結果 (parseTaskFile(patched))
|
|
156
|
+
*/
|
|
157
|
+
export function verifyPinpointResult(before, after, patch) {
|
|
158
|
+
const beforeData = before.data;
|
|
159
|
+
const afterData = after.data;
|
|
160
|
+
// patch が意図する各フィールドの最終値 (blocked_* は patch に無ければ変更しない)
|
|
161
|
+
const intended = {
|
|
162
|
+
status: patch.status,
|
|
163
|
+
updated: patch.updated,
|
|
164
|
+
};
|
|
165
|
+
if ('blocked_type' in patch)
|
|
166
|
+
intended.blocked_type = patch.blocked_type ?? null;
|
|
167
|
+
if ('blocked_reason' in patch)
|
|
168
|
+
intended.blocked_reason = patch.blocked_reason ?? null;
|
|
169
|
+
const intendedKeys = new Set(Object.keys(intended));
|
|
170
|
+
// patch 対象フィールドが意図どおりの値になっているか
|
|
171
|
+
for (const [key, value] of Object.entries(intended)) {
|
|
172
|
+
if (!valuesEqual(afterData[key], value)) {
|
|
173
|
+
return `行置換が ${key} を正しく書き換えられなかった (この表記はピンポイント書換に未対応)`;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// patch 対象外のフィールドが変わっていないか (block scalar の孤児化等で巻き添えが出ないか)。
|
|
177
|
+
// 両者のキー集合の和を走査し、patch 対象外で差分があれば拒否する
|
|
178
|
+
const allKeys = new Set([...Object.keys(beforeData), ...Object.keys(afterData)]);
|
|
179
|
+
for (const key of allKeys) {
|
|
180
|
+
if (intendedKeys.has(key))
|
|
181
|
+
continue;
|
|
182
|
+
if (!valuesEqual(beforeData[key], afterData[key])) {
|
|
183
|
+
return `行置換が意図しないフィールド ${key} を変更した (この表記はピンポイント書換に未対応)`;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// 本文 (body) は frontmatter の行置換で変わってはならない (R2-2)
|
|
187
|
+
if (before.body !== after.body) {
|
|
188
|
+
return '行置換が本文を変更した (この表記はピンポイント書換に未対応)';
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
/** JSON 直列化で値の等価判定をする (frontmatter の値はスカラー / 配列 / null のみ) */
|
|
193
|
+
function valuesEqual(a, b) {
|
|
194
|
+
if (a === b)
|
|
195
|
+
return true;
|
|
196
|
+
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* verify-after-rename ガード (R8-4)。renameTaskId の結果を再 parse して検証する。
|
|
200
|
+
* 「新 id が newId (= ファイル名) と一致 & id 以外の frontmatter フィールドと body は不変」を確認。
|
|
201
|
+
* drift があれば理由文字列を返す (呼び出し側で書込を拒否し元ファイルを残す)。問題なければ null。
|
|
202
|
+
*
|
|
203
|
+
* @param before rename 前の parse 結果 (parseTaskFile(raw))
|
|
204
|
+
* @param after rename 後の parse 結果 (parseTaskFile(renamed))
|
|
205
|
+
* @param newId 期待する新 ID (= 新ファイル名の basename)
|
|
206
|
+
*/
|
|
207
|
+
export function verifyRename(before, after, newId) {
|
|
208
|
+
// id が確実に newId へ変わったか (= ファイル名と frontmatter id の整合)
|
|
209
|
+
if (after.data.id !== newId) {
|
|
210
|
+
return `id が ${newId} に書き換わっていない (この id 表記はリネームに未対応)`;
|
|
211
|
+
}
|
|
212
|
+
// id 以外の全フィールドは不変であること (壊れた id 表記で他フィールドを巻き添えにしていないか)
|
|
213
|
+
const allKeys = new Set([...Object.keys(before.data), ...Object.keys(after.data)]);
|
|
214
|
+
for (const key of allKeys) {
|
|
215
|
+
if (key === 'id')
|
|
216
|
+
continue;
|
|
217
|
+
if (!valuesEqual(before.data[key], after.data[key])) {
|
|
218
|
+
return `リネームが意図しないフィールド ${key} を変更した`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (before.body !== after.body) {
|
|
222
|
+
return 'リネームが本文を変更した';
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 提案ワークフロー (F-3) の本文操作。却下時に作業ログへ理由を追記する純関数。
|
|
3
|
+
*/
|
|
4
|
+
// 作業ログ見出し: 日本語テンプレ (`## 作業ログ`) と英語 (`## Work log`) の両方を許容する
|
|
5
|
+
const WORK_LOG_HEADING_RE = /^#{1,6}\s*(?:作業ログ|work log)\s*$/i;
|
|
6
|
+
// 承認痕跡: approve API が作業ログに残す `[approve]` タグ行 (F-3-4 誤検知防止 R6-1)
|
|
7
|
+
const APPROVE_TRACE_RE = /^\s*-\s*\S.*\[approve\]/m;
|
|
8
|
+
/**
|
|
9
|
+
* 作業ログに承認痕跡 (`[approve]` を含むログ行) があるかを判定する。
|
|
10
|
+
* proposed→approve した正規タスクと、AI が proposed を経ず直接 todo 起票した違反を区別するために使う。
|
|
11
|
+
*/
|
|
12
|
+
export function hasApprovalTrace(body) {
|
|
13
|
+
return APPROVE_TRACE_RE.test(body);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* frontmatter ブロック (先頭 `---` 〜 2 つ目の `---` の行) を header として保持したまま、
|
|
17
|
+
* 本文 (body) だけを新しい内容に差し替えた raw 全文を返す。
|
|
18
|
+
* frontmatter が無ければ全体を body 扱いにして newBody を返す。
|
|
19
|
+
* BOM・CRLF は header 側でそのまま保全される (行置換しないため frontmatter はバイト不変)。
|
|
20
|
+
*/
|
|
21
|
+
export function replaceBody(raw, newBody) {
|
|
22
|
+
const bom = raw.startsWith('') ? '' : '';
|
|
23
|
+
const text = bom ? raw.slice(1) : raw;
|
|
24
|
+
const lines = text.split('\n');
|
|
25
|
+
if ((lines[0] ?? '').replace(/\r$/, '') !== '---') {
|
|
26
|
+
// frontmatter 無し: 全体が本文
|
|
27
|
+
return newBody;
|
|
28
|
+
}
|
|
29
|
+
let closeIndex = -1;
|
|
30
|
+
for (let i = 1; i < lines.length; i++) {
|
|
31
|
+
if ((lines[i] ?? '').replace(/\r$/, '') === '---') {
|
|
32
|
+
closeIndex = i;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (closeIndex === -1) {
|
|
37
|
+
return newBody; // 閉じ --- が無い壊れたファイル: 防御的に body 扱い
|
|
38
|
+
}
|
|
39
|
+
// header = 先頭 〜 閉じ --- 行 (その行の EOL は含む)。本文はその直後から
|
|
40
|
+
const header = lines.slice(0, closeIndex + 1).join('\n');
|
|
41
|
+
// 元 raw の「閉じ --- 行の直後の改行」を保つため、header の後に \n + newBody を置く。
|
|
42
|
+
// gray-matter の content は閉じ --- 直後の 1 改行を取り除いた形なので、ここで 1 つ補う
|
|
43
|
+
return `${bom}${header}\n${newBody}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* タスク本文の `## 作業ログ` セクション末尾に 1 行追記する。
|
|
47
|
+
* - セクションがあれば、その内容ブロックの末尾 (次見出し or EOF 直前) に挿入
|
|
48
|
+
* - セクションが無ければ、本文末尾に `## 作業ログ` 見出しごと追加
|
|
49
|
+
* 改行入りの line は受け付けない (呼び出し側で 1 行に正規化する前提)。
|
|
50
|
+
* EOL は本文の既存スタイル (CRLF / LF) に合わせる (R6 Minor: CRLF ファイルに LF 行を混在させない)。
|
|
51
|
+
*/
|
|
52
|
+
export function appendWorkLogLine(body, line) {
|
|
53
|
+
return appendUnderHeading(body, WORK_LOG_HEADING_RE, '## 作業ログ', line);
|
|
54
|
+
}
|
|
55
|
+
// やりとり (タスクコメント) 見出し: 日本語 (`## やりとり`) と英語 (`## Comments` / `## Discussion`) を許容
|
|
56
|
+
const COMMENT_HEADING_RE = /^#{1,6}\s*(?:やりとり|comments?|discussion)\s*$/i;
|
|
57
|
+
/**
|
|
58
|
+
* タスクコメント (往復) を本文の `## やりとり` セクション末尾に 1 行追記する。
|
|
59
|
+
* 作業ログと同形式 (`- 時刻 [member] msg`)。セクションが無ければ見出しごと追加する。
|
|
60
|
+
*/
|
|
61
|
+
export function appendCommentLine(body, line) {
|
|
62
|
+
return appendUnderHeading(body, COMMENT_HEADING_RE, '## やりとり', line);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 指定見出しのセクション末尾に `- <line>` を追記する共通処理 (作業ログ / やりとり で共用)。
|
|
66
|
+
* - セクションがあればその内容ブロック末尾 (次見出し or EOF 直前) に挿入
|
|
67
|
+
* - 無ければ本文末尾に見出しごと追加
|
|
68
|
+
* EOL は本文の既存スタイル (CRLF / LF) に合わせる (R6 Minor: 混在させない)。
|
|
69
|
+
*/
|
|
70
|
+
function appendUnderHeading(body, headingRe, defaultHeading, line) {
|
|
71
|
+
const eol = body.includes('\r\n') ? '\r\n' : '\n';
|
|
72
|
+
const entry = `- ${line}`;
|
|
73
|
+
const lines = body.split(eol);
|
|
74
|
+
const headingIndex = lines.findIndex((l) => headingRe.test(l.trim()));
|
|
75
|
+
if (headingIndex === -1) {
|
|
76
|
+
const trimmed = body.replace(/(\r?\n)+$/, '');
|
|
77
|
+
const prefix = trimmed.length > 0 ? `${trimmed}${eol}${eol}` : '';
|
|
78
|
+
return `${prefix}${defaultHeading}${eol}${entry}${eol}`;
|
|
79
|
+
}
|
|
80
|
+
let insertAt = lines.length;
|
|
81
|
+
for (let i = headingIndex + 1; i < lines.length; i++) {
|
|
82
|
+
if (/^#{1,6}\s/.test(lines[i])) {
|
|
83
|
+
insertAt = i;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
let pos = insertAt;
|
|
88
|
+
while (pos > headingIndex + 1 && lines[pos - 1].trim() === '') {
|
|
89
|
+
pos -= 1;
|
|
90
|
+
}
|
|
91
|
+
lines.splice(pos, 0, entry);
|
|
92
|
+
return lines.join(eol);
|
|
93
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import matter from 'gray-matter';
|
|
2
|
+
import yaml from 'js-yaml';
|
|
3
|
+
/**
|
|
4
|
+
* タスクファイル (Markdown + YAML frontmatter) の parse / serialize。
|
|
5
|
+
*
|
|
6
|
+
* - parse: gray-matter に js-yaml の JSON_SCHEMA エンジンを注入し、
|
|
7
|
+
* `created: 2026-06-10T10:00:00+09:00` のような日付を Date 化せず文字列のまま扱う
|
|
8
|
+
* (既定エンジンは YAML 1.1 timestamp として Date 化し、再出力で UTC に書き換わるため)。
|
|
9
|
+
* - serialize: 新規作成専用。既存ファイルの更新は pinpoint.ts の行置換を使い、
|
|
10
|
+
* parse → stringify の再シリアライズは行わない (クォート・空白の正規化で git diff が壊れるため)。
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* YAML 構文エラー。js-yaml の YAMLException と違い、message にファイル内容の抜粋を
|
|
14
|
+
* 含まない (N-8: タスク本文・設定内容をサーバーログへ出さない契約のため、ログ出力可)。
|
|
15
|
+
*
|
|
16
|
+
* js-yaml の reason は基本的に固定の英語テンプレートだが、一部のエラー
|
|
17
|
+
* (`unidentified alias "<名前>"` / `unknown tag !<...>`) はファイル由来の識別子を
|
|
18
|
+
* 引用・山括弧で埋め込む。これらは sanitizeYamlReason で伏字化してから持たせる。
|
|
19
|
+
*/
|
|
20
|
+
export class TaskFileParseError extends Error {
|
|
21
|
+
line;
|
|
22
|
+
column;
|
|
23
|
+
constructor(message, line, column) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'TaskFileParseError';
|
|
26
|
+
this.line = line;
|
|
27
|
+
this.column = column;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function isPlainObject(value) {
|
|
31
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* js-yaml の reason からファイル由来の文字列を伏字化する (N-8)。
|
|
35
|
+
*
|
|
36
|
+
* js-yaml@4 がファイル内容を reason に埋め込むテンプレートは 3 系統:
|
|
37
|
+
* 1. 引用符の中: `unidentified alias "SECRET"` / `undeclared tag handle "!x!"`
|
|
38
|
+
* 2. 山括弧の中: `unknown tag !<tag:...:python/secret>`
|
|
39
|
+
* 3. **区切りなし**で `": "` (コロン + 空白) の直後にベタ書き:
|
|
40
|
+
* `tag name cannot contain such characters: <生内容>` (loader.js:1316) /
|
|
41
|
+
* `tag name is malformed: <生内容>` (loader.js:1322) /
|
|
42
|
+
* `tag prefix is malformed: <生内容>` (loader.js:346)
|
|
43
|
+
*
|
|
44
|
+
* 1/2 は中身だけ伏字化し、3 は `": "` 以降を丸ごと固定文言に倒す。
|
|
45
|
+
* js-yaml の安全な reason は top-level に `": "` を持たないため過剰伏字にならない
|
|
46
|
+
* (実測確認済み)。将来 js-yaml が `prefix: content` 形の新テンプレートを足しても
|
|
47
|
+
* この `": "` ルールで素通しを防げる。
|
|
48
|
+
*
|
|
49
|
+
* R14-2 (N-8): 最終ルールの `.*` は改行非マッチのため、複数行 verbatim tag を含む reason で
|
|
50
|
+
* 改行後のタスク本文が伏字化されずエラー / レスポンスに残る漏洩があった。`[\s\S]` で改行込み全消去にする。
|
|
51
|
+
*/
|
|
52
|
+
export function sanitizeYamlReason(reason) {
|
|
53
|
+
return reason
|
|
54
|
+
.replace(/"[^"]*"/g, '"***"')
|
|
55
|
+
.replace(/'[^']*'/g, "'***'")
|
|
56
|
+
.replace(/<[^>]*>/g, '<***>')
|
|
57
|
+
.replace(/: [\s\S]*$/, ': ***');
|
|
58
|
+
}
|
|
59
|
+
const yamlEngine = {
|
|
60
|
+
parse(input) {
|
|
61
|
+
let loaded;
|
|
62
|
+
try {
|
|
63
|
+
loaded = yaml.load(input, { schema: yaml.JSON_SCHEMA });
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
// YAMLException.message はエラー位置周辺のファイル内容を複数行含むため、
|
|
67
|
+
// 位置情報だけを持つ専用エラーに変換する (N-8)。reason に紛れたファイル由来の
|
|
68
|
+
// 識別子 (alias / tag 名) も sanitizeYamlReason で伏字化する
|
|
69
|
+
const mark = error.mark;
|
|
70
|
+
const rawReason = error.reason ?? '不明な構文エラー';
|
|
71
|
+
const reason = sanitizeYamlReason(rawReason);
|
|
72
|
+
const line = typeof mark?.line === 'number' ? mark.line + 1 : null;
|
|
73
|
+
const column = typeof mark?.column === 'number' ? mark.column + 1 : null;
|
|
74
|
+
const position = line !== null ? ` (${line} 行目${column !== null ? ` ${column} 文字目` : ''})` : '';
|
|
75
|
+
throw new TaskFileParseError(`YAML 構文エラー: ${reason}${position}`, line, column);
|
|
76
|
+
}
|
|
77
|
+
// スカラーや配列だけの frontmatter は不正なので空に正規化する (zod の必須欠落として検出される)
|
|
78
|
+
return isPlainObject(loaded) ? loaded : {};
|
|
79
|
+
},
|
|
80
|
+
stringify() {
|
|
81
|
+
throw new Error('既存タスクの再シリアライズは禁止。新規作成は serializeTaskFile() を使う');
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* タスクファイルの生テキストを frontmatter と本文に分解する。
|
|
86
|
+
* YAML 構文エラー時は TaskFileParseError を投げる (呼び出し側で警告に変換する)。
|
|
87
|
+
* 先頭の UTF-8 BOM は除いて扱う (gray-matter 内部の BOM 除去と判定を揃える)。
|
|
88
|
+
*/
|
|
89
|
+
export function parseTaskFile(raw) {
|
|
90
|
+
const text = raw.startsWith('\uFEFF') ? raw.slice(1) : raw;
|
|
91
|
+
const file = matter(text, { engines: { yaml: yamlEngine } });
|
|
92
|
+
return {
|
|
93
|
+
data: file.data,
|
|
94
|
+
body: file.content,
|
|
95
|
+
// gray-matter \u306F\u958B\u304D `---` \u306E\u884C\u672B\u306B\u7A7A\u767D / \u30BF\u30D6\u304C\u4ED8\u3044\u3066\u3082 frontmatter \u3068\u3057\u3066\u53D7\u7406\u3059\u308B\u3002
|
|
96
|
+
// \u5224\u5B9A\u3092\u305D\u308C\u306B\u5408\u308F\u305B\u300C\u30DC\u30FC\u30C9\u306B\u306F\u51FA\u308B\u306E\u306B hasFrontmatter=false\u300D\u306E\u975E\u5BFE\u79F0\u3092\u9632\u3050
|
|
97
|
+
hasFrontmatter: /^---[ \t]*\r?\n/.test(text),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 新規タスクファイル生成専用のシリアライザ。フィールドは要件 §5.3 の表の順で出力する。
|
|
102
|
+
* depends_on は任意フィールドのため、空のときは行ごと省略する (§5.3 のファイル例に合わせる)。
|
|
103
|
+
*/
|
|
104
|
+
export function serializeTaskFile(frontmatter, body) {
|
|
105
|
+
const ordered = {
|
|
106
|
+
id: frontmatter.id,
|
|
107
|
+
title: frontmatter.title,
|
|
108
|
+
status: frontmatter.status,
|
|
109
|
+
assignee: frontmatter.assignee,
|
|
110
|
+
created_by: frontmatter.created_by,
|
|
111
|
+
priority: frontmatter.priority,
|
|
112
|
+
labels: frontmatter.labels,
|
|
113
|
+
blocked_type: frontmatter.blocked_type,
|
|
114
|
+
blocked_reason: frontmatter.blocked_reason,
|
|
115
|
+
...(frontmatter.depends_on.length > 0 ? { depends_on: frontmatter.depends_on } : {}),
|
|
116
|
+
// milestone は任意フィールド。null のときは行ごと省略する (depends_on と同様・後方互換)
|
|
117
|
+
...(frontmatter.milestone !== null ? { milestone: frontmatter.milestone } : {}),
|
|
118
|
+
// related_docs (波 VIII) は未指定 / 空なら行ごと省略 (既存ファイルと差分ゼロ・後方互換)
|
|
119
|
+
...(frontmatter.related_docs !== undefined && frontmatter.related_docs.length > 0
|
|
120
|
+
? { related_docs: frontmatter.related_docs }
|
|
121
|
+
: {}),
|
|
122
|
+
created: frontmatter.created,
|
|
123
|
+
updated: frontmatter.updated,
|
|
124
|
+
};
|
|
125
|
+
const yamlText = yaml.dump(ordered, {
|
|
126
|
+
schema: yaml.JSON_SCHEMA, // 日付を文字列のまま出力する (parse と対)
|
|
127
|
+
flowLevel: 1, // labels: [backend, auth] の flow 形式 (§5.3 の例)
|
|
128
|
+
lineWidth: -1, // 長い title を折り返さない
|
|
129
|
+
noRefs: true,
|
|
130
|
+
});
|
|
131
|
+
return `---\n${yamlText}---\n${body}`;
|
|
132
|
+
}
|