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.
@@ -0,0 +1,206 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { findMemberViolations, moveInputSchema, newTaskInputSchema, NEW_TASK_BODY, summarizeZodError, taskFrontmatterSchema, taskIdSchema, } from '../../shared/schema.js';
4
+ import { loadConfig, loadTaskView } from '../board.js';
5
+ import { allocateNextId, createSerialQueue } from './id.js';
6
+ import { FileExistsError, safeWriteFile } from './fs-safe.js';
7
+ import { applyPinpointPatch, verifyPinpointResult } from './pinpoint.js';
8
+ import { appendCommentLine, appendWorkLogLine, replaceBody } from './proposal.js';
9
+ import { parseTaskFile, serializeTaskFile, TaskFileParseError } from './task-file.js';
10
+ import { nowIso, workLogTimestamp } from './time.js';
11
+ /**
12
+ * タスク操作のコアロジック (AI 用 CLI F-6-3 が使う)。
13
+ *
14
+ * 既存の shared / server プリミティブ (schema 検証・採番直列化・pinpoint + verify・パス検証・
15
+ * 作業ログ追記) を組み合わせるだけの薄い orchestration。HTTP を経由せずファイルを直接操作する
16
+ * (serve 不要)。HTTP ルート (app.ts) と同じ不変条件を満たす (zod 検証 / 直列化 / verify-after-patch)。
17
+ *
18
+ * エラーは TaskOpError (code 付き) で投げる。N-8: メッセージにファイル本文・設定内容を含めない。
19
+ */
20
+ const NEW_TASK_STATUS_DEFAULT = 'todo';
21
+ export class TaskOpError extends Error {
22
+ code;
23
+ /** zod 由来の検証メッセージ (フィールド名 + 制約。本文は含まない) */
24
+ issues;
25
+ constructor(code, message, issues) {
26
+ super(message);
27
+ this.name = 'TaskOpError';
28
+ this.code = code;
29
+ this.issues = issues;
30
+ }
31
+ }
32
+ /** 1 プロジェクトに対するタスク操作。CLI は projectRoot 解決後にこのクラスを使う */
33
+ export class TaskOps {
34
+ projectRoot;
35
+ kanbanDir;
36
+ tasksDir;
37
+ enqueue = createSerialQueue();
38
+ constructor(projectRoot) {
39
+ this.projectRoot = path.resolve(projectRoot);
40
+ this.kanbanDir = path.join(this.projectRoot, '.kanban');
41
+ this.tasksDir = path.join(this.kanbanDir, 'tasks');
42
+ }
43
+ taskPath(id) {
44
+ return path.join(this.tasksDir, `${id}.md`);
45
+ }
46
+ /** 起票 (採番直列化・schema 検証・members 整合)。POST /api/tasks と同じロジック */
47
+ async create(input) {
48
+ // CLI は起票時の status を任意指定できる (HTTP POST は todo 固定だが、AI が backlog/proposed 等で
49
+ // 起票したいケースに対応)。status は newTaskInputSchema (.strict) の対象外なので分離してから検証する
50
+ const { status: requestedStatus, ...rest } = input;
51
+ const status = requestedStatus ?? NEW_TASK_STATUS_DEFAULT;
52
+ const parsed = newTaskInputSchema.safeParse(rest);
53
+ if (!parsed.success) {
54
+ throw new TaskOpError('invalid_input', '入力が不正', summarizeZodError(parsed.error));
55
+ }
56
+ const { config } = await loadConfig(this.projectRoot);
57
+ const violations = findMemberViolations({ created_by: parsed.data.created_by, assignee: parsed.data.assignee ?? null }, config);
58
+ if (violations.length > 0) {
59
+ throw new TaskOpError('unregistered_member', violations.join(' / '));
60
+ }
61
+ return this.enqueue(async () => {
62
+ const MAX_RETRY = 5;
63
+ for (let attempt = 0;; attempt++) {
64
+ const id = await allocateNextId(this.kanbanDir, config.idPrefix);
65
+ const now = nowIso();
66
+ const fmInput = {
67
+ id,
68
+ title: parsed.data.title,
69
+ status,
70
+ assignee: parsed.data.assignee ?? null,
71
+ created_by: parsed.data.created_by,
72
+ priority: parsed.data.priority ?? 'medium',
73
+ labels: parsed.data.labels ?? [],
74
+ blocked_type: null,
75
+ blocked_reason: null,
76
+ created: now,
77
+ updated: now,
78
+ };
79
+ const fmResult = taskFrontmatterSchema.safeParse(fmInput);
80
+ if (!fmResult.success) {
81
+ // pending を blocked_* 無しで起票しようとした等 (CLI の add では pending を避ける想定)
82
+ throw new TaskOpError('invalid_input', '起票内容が不正', summarizeZodError(fmResult.error));
83
+ }
84
+ const filePath = this.taskPath(id);
85
+ try {
86
+ await safeWriteFile(filePath, serializeTaskFile(fmResult.data, NEW_TASK_BODY), [this.kanbanDir], {
87
+ exclusive: true,
88
+ });
89
+ return loadTaskView(this.projectRoot, filePath);
90
+ }
91
+ catch (error) {
92
+ if (error instanceof FileExistsError && attempt < MAX_RETRY)
93
+ continue;
94
+ throw error;
95
+ }
96
+ }
97
+ });
98
+ }
99
+ /** ステータス変更 (pinpoint + verify)。PATCH /api/tasks/:id と同じロジック */
100
+ async move(id, input) {
101
+ if (!taskIdSchema.safeParse(id).success) {
102
+ throw new TaskOpError('invalid_input', '不正なタスク ID');
103
+ }
104
+ const parsed = moveInputSchema.safeParse(input);
105
+ if (!parsed.success) {
106
+ throw new TaskOpError('invalid_input', '入力が不正', summarizeZodError(parsed.error));
107
+ }
108
+ const filePath = this.taskPath(id);
109
+ return this.enqueue(async () => {
110
+ let raw;
111
+ try {
112
+ raw = await fs.readFile(filePath, 'utf8');
113
+ }
114
+ catch {
115
+ throw new TaskOpError('not_found', `タスクが見つからない: ${id}`);
116
+ }
117
+ let current;
118
+ try {
119
+ current = parseTaskFile(raw);
120
+ }
121
+ catch (error) {
122
+ const message = error instanceof TaskFileParseError ? error.message : 'タスクファイルを解析できない';
123
+ throw new TaskOpError('conflict', `更新できない (${message})`);
124
+ }
125
+ const patch = { status: parsed.data.status, updated: nowIso() };
126
+ if (parsed.data.status === 'pending') {
127
+ patch.blocked_type = parsed.data.blocked_type ?? null;
128
+ patch.blocked_reason = parsed.data.blocked_reason ?? null;
129
+ }
130
+ else if (current.data.blocked_type != null || current.data.blocked_reason != null) {
131
+ patch.blocked_type = null;
132
+ patch.blocked_reason = null;
133
+ }
134
+ let patched;
135
+ let patchedParsed;
136
+ try {
137
+ patched = applyPinpointPatch(raw, patch);
138
+ patchedParsed = parseTaskFile(patched);
139
+ }
140
+ catch (error) {
141
+ const message = error instanceof Error ? error.message : '更新に失敗';
142
+ throw new TaskOpError('conflict', `更新できない (${message})`);
143
+ }
144
+ const drift = verifyPinpointResult(current, patchedParsed, patch);
145
+ if (drift !== null) {
146
+ throw new TaskOpError('conflict', `更新できない (${drift})`);
147
+ }
148
+ const check = taskFrontmatterSchema.safeParse(patchedParsed.data);
149
+ if (!check.success) {
150
+ throw new TaskOpError('invalid_input', 'ステータス変更後のタスクが不正', summarizeZodError(check.error));
151
+ }
152
+ await safeWriteFile(filePath, patched, [this.kanbanDir]);
153
+ return loadTaskView(this.projectRoot, filePath);
154
+ });
155
+ }
156
+ /** 作業ログ追記 (## 作業ログ に 1 行)。frontmatter はバイト保全 */
157
+ async appendLog(id, member, message) {
158
+ return this.appendToSection(id, member, message, 'log');
159
+ }
160
+ /** やりとり追記 (## やりとり に 1 行)。member は config.members 検証 */
161
+ async appendComment(id, member, message) {
162
+ const { config } = await loadConfig(this.projectRoot);
163
+ if (!config.members.some((m) => m.id === member)) {
164
+ throw new TaskOpError('unregistered_member', `member "${member}" は config.members に未登録`);
165
+ }
166
+ return this.appendToSection(id, member, message, 'comment');
167
+ }
168
+ async appendToSection(id, member, message, section) {
169
+ if (!taskIdSchema.safeParse(id).success) {
170
+ throw new TaskOpError('invalid_input', '不正なタスク ID');
171
+ }
172
+ if (member.length === 0 || /[\r\n]/.test(member) || message.length === 0 || /[\r\n]/.test(message)) {
173
+ throw new TaskOpError('invalid_input', 'member / message は改行を含まない非空の値が必要');
174
+ }
175
+ const filePath = this.taskPath(id);
176
+ return this.enqueue(async () => {
177
+ let raw;
178
+ try {
179
+ raw = await fs.readFile(filePath, 'utf8');
180
+ }
181
+ catch {
182
+ throw new TaskOpError('not_found', `タスクが見つからない: ${id}`);
183
+ }
184
+ let current;
185
+ try {
186
+ current = parseTaskFile(raw);
187
+ }
188
+ catch (error) {
189
+ const m = error instanceof TaskFileParseError ? error.message : 'タスクファイルを解析できない';
190
+ throw new TaskOpError('conflict', `追記できない (${m})`);
191
+ }
192
+ // R9-1: 末尾の loadTaskView が taskFrontmatterSchema.parse() で raw ZodError を投げると、
193
+ // 「書込は成功済みなのに exit≠0」となり呼び出し側が再実行 → 同じ行が二重追記される。
194
+ // move/create と同じく「書込前に safeParse で弾く」対称性を取り、不正な frontmatter
195
+ // (status enum 外・未知キー等の AI 生編集 typo) は書込前に一貫した TaskOpError にする。
196
+ const fmCheck = taskFrontmatterSchema.safeParse(current.data);
197
+ if (!fmCheck.success) {
198
+ throw new TaskOpError('invalid_input', '追記前のタスクの frontmatter が不正', summarizeZodError(fmCheck.error));
199
+ }
200
+ const line = `${workLogTimestamp()} [${member}] ${message}`;
201
+ const newBody = section === 'log' ? appendWorkLogLine(current.body, line) : appendCommentLine(current.body, line);
202
+ await safeWriteFile(filePath, replaceBody(raw, newBody), [this.kanbanDir]);
203
+ return loadTaskView(this.projectRoot, filePath);
204
+ });
205
+ }
206
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * タイムゾーン付き ISO 8601 文字列の生成 (要件 §5.3: created / updated)。
3
+ * UTC へ変換せず、ローカルタイムゾーンのオフセット表記 (例: 2026-06-10T14:30:00+09:00) で出力する。
4
+ */
5
+ export function nowIso(date = new Date()) {
6
+ const pad = (value) => String(value).padStart(2, '0');
7
+ const offsetMinutes = -date.getTimezoneOffset();
8
+ const sign = offsetMinutes >= 0 ? '+' : '-';
9
+ const absOffset = Math.abs(offsetMinutes);
10
+ return (`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
11
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
12
+ `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`);
13
+ }
14
+ /** アーカイブ先の月別フォルダ名 `YYYY-MM` (アーカイブ操作を実行した月基準、要件 §5.1) */
15
+ export function archiveMonth(date = new Date()) {
16
+ const pad = (value) => String(value).padStart(2, '0');
17
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
18
+ }
19
+ /** 作業ログ行の時刻表記 `YYYY-MM-DD HH:mm` (ローカル時刻、§5.3 の作業ログ書式) */
20
+ export function workLogTimestamp(date = new Date()) {
21
+ const pad = (value) => String(value).padStart(2, '0');
22
+ return (`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
23
+ `${pad(date.getHours())}:${pad(date.getMinutes())}`);
24
+ }
@@ -0,0 +1,119 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Hono } from 'hono';
4
+ import { HTTPException } from 'hono/http-exception';
5
+ import { streamSSE } from 'hono/streaming';
6
+ import { applySecurityMiddleware } from './app.js';
7
+ import { loadBoard } from './board.js';
8
+ import { createEventBus } from './events.js';
9
+ /** 表示用 dir 名 (basename)。同名ルートは " (2)" 連番で一意化する */
10
+ export function displayDirNames(roots) {
11
+ const seen = new Map();
12
+ return roots.map((root) => {
13
+ const base = path.basename(root);
14
+ const count = (seen.get(base) ?? 0) + 1;
15
+ seen.set(base, count);
16
+ return count === 1 ? base : `${base} (${count})`;
17
+ });
18
+ }
19
+ const ATTENTION_STATUSES = ['in_progress', 'pending', 'proposed'];
20
+ function isAttentionStatus(status) {
21
+ return ATTENTION_STATUSES.includes(status);
22
+ }
23
+ /** 1 ルート分のサマリ + 注目タスクを loadBoard から導出する */
24
+ async function summarizeRoot(root, dir) {
25
+ const board = await loadBoard(root);
26
+ const statusCounts = { proposed: 0, backlog: 0, todo: 0, in_progress: 0, pending: 0, done: 0 };
27
+ for (const task of board.tasks) {
28
+ statusCounts[task.frontmatter.status] += 1;
29
+ }
30
+ // WIP 超過: wipLimit のあるカラムで status 件数 > wipLimit (web の Column.tsx と同じ判定)
31
+ const countFor = (statusId) => board.tasks.filter((t) => t.frontmatter.status === statusId).length;
32
+ const wipExceeded = board.config.columns.some((col) => col.wipLimit !== undefined && countFor(col.id) > col.wipLimit);
33
+ // 表示名: config.projectName。config が「有効に読めていない」場合は dir basename にフォールバック (R12-3)。
34
+ // (1) config.json 不在 (loadConfig は不在でも既定値 "project" を返すため、不在判定はファイル存在で行う)
35
+ // (2) config.json は存在するが JSON 破損 / schema 不正 (loadBoard の warnings に config.json 由来の
36
+ // invalid_config が載る。tasks ディレクトリ不在の invalid_config と区別するため path で絞る)
37
+ let configValid = true;
38
+ try {
39
+ await fs.access(path.join(root, '.kanban', 'config.json'));
40
+ }
41
+ catch {
42
+ configValid = false;
43
+ }
44
+ if (configValid && board.warnings.some((w) => w.kind === 'invalid_config' && w.path.endsWith('config.json'))) {
45
+ configValid = false;
46
+ }
47
+ const name = configValid ? board.config.projectName : path.basename(root);
48
+ const tasks = board.tasks
49
+ .filter((task) => isAttentionStatus(task.frontmatter.status))
50
+ .map((task) => ({
51
+ id: task.frontmatter.id,
52
+ title: task.frontmatter.title,
53
+ status: task.frontmatter.status,
54
+ assignee: task.frontmatter.assignee,
55
+ blocked_type: task.frontmatter.blocked_type,
56
+ blocked_reason: task.frontmatter.blocked_reason,
57
+ project: dir,
58
+ }));
59
+ return {
60
+ project: { name, dir, root, statusCounts, warningCount: board.warnings.length, wipExceeded },
61
+ tasks,
62
+ };
63
+ }
64
+ /** 注目タスクの表示順: in_progress → pending → proposed、同順位は project → id 昇順 */
65
+ const ATTENTION_ORDER = { in_progress: 0, pending: 1, proposed: 2 };
66
+ export async function buildOverview(roots) {
67
+ const dirs = displayDirNames(roots);
68
+ const projects = [];
69
+ const attention = [];
70
+ for (let i = 0; i < roots.length; i++) {
71
+ const { project, tasks } = await summarizeRoot(roots[i], dirs[i]);
72
+ projects.push(project);
73
+ attention.push(...tasks);
74
+ }
75
+ attention.sort((a, b) => ATTENTION_ORDER[a.status] - ATTENTION_ORDER[b.status] ||
76
+ a.project.localeCompare(b.project) ||
77
+ a.id.localeCompare(b.id, 'en', { numeric: true }));
78
+ return { projects, attention };
79
+ }
80
+ export function createOverviewApp(options) {
81
+ const projectRoots = options.projectRoots.map((root) => path.resolve(root));
82
+ const bus = options.bus ?? createEventBus();
83
+ const app = new Hono();
84
+ // セキュリティミドルウェア (CSP / Host / CSRF / bodyLimit) は board と共通 (R14-5)。
85
+ // overview は現状 GET のみだが CSRF 等の非対称を避けるため board と同一にする (LEARN#8b で共通化)
86
+ applySecurityMiddleware(app);
87
+ app.onError((error, c) => {
88
+ if (error instanceof HTTPException) {
89
+ return error.getResponse();
90
+ }
91
+ // N-8: タスク本文・設定内容・エラー詳細はログに出さない (種別のみ)
92
+ console.error('[aizuchi:overview]', c.req.method, c.req.path, error.name);
93
+ return c.json({ error: 'サーバー内部エラー' }, 500);
94
+ });
95
+ // GET /api/mode — SPA の起動時分岐用
96
+ app.get('/api/mode', (c) => {
97
+ return c.json({ mode: 'overview' });
98
+ });
99
+ // GET /api/overview — 全ルートのサマリ + 注目タスク (read-only)
100
+ app.get('/api/overview', async (c) => {
101
+ return c.json(await buildOverview(projectRoots));
102
+ });
103
+ // GET /api/events — SSE (通知のみ。クライアントは /api/overview を全量再取得する)
104
+ app.get('/api/events', (c) => {
105
+ return streamSSE(c, async (stream) => {
106
+ let unsubscribe = () => { };
107
+ const closed = new Promise((resolve) => {
108
+ stream.onAbort(() => resolve());
109
+ });
110
+ unsubscribe = bus.subscribe((event) => {
111
+ void stream.writeSSE({ data: JSON.stringify(event) });
112
+ });
113
+ await stream.writeSSE({ data: JSON.stringify({ type: 'connected' }) });
114
+ await closed;
115
+ unsubscribe();
116
+ });
117
+ });
118
+ return { app, bus };
119
+ }
@@ -0,0 +1,71 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ /**
4
+ * ビルド済み SPA (dist/web) の静的配信。serve コマンドが API ルートの後段に取り付ける。
5
+ * アセットはパッケージ内ディレクトリにあるため、cwd 基準の serveStatic は使わず
6
+ * 自前でパス検証 (traversal 拒否) 付きの配信を行う。
7
+ */
8
+ const CONTENT_TYPES = {
9
+ '.html': 'text/html; charset=utf-8',
10
+ '.js': 'text/javascript; charset=utf-8',
11
+ '.css': 'text/css; charset=utf-8',
12
+ '.svg': 'image/svg+xml',
13
+ '.png': 'image/png',
14
+ '.ico': 'image/x-icon',
15
+ '.json': 'application/json; charset=utf-8',
16
+ '.map': 'application/json',
17
+ '.woff2': 'font/woff2',
18
+ '.woff': 'font/woff',
19
+ };
20
+ export function attachStaticAssets(app, webDistDir) {
21
+ const root = path.resolve(webDistDir);
22
+ // favicon は dist/web に同梱しないため、無ければ 204 を返してブラウザの 404 連発を避ける
23
+ // (dist/web/favicon.* があれば下の catch-all がそちらを配信する)
24
+ app.get('/favicon.ico', async (c, next) => {
25
+ try {
26
+ await fs.access(path.join(root, 'favicon.ico'));
27
+ return next();
28
+ }
29
+ catch {
30
+ return c.body(null, 204);
31
+ }
32
+ });
33
+ app.get('*', async (c) => {
34
+ // 不正な %エンコード (例: "/%") は decodeURIComponent が URIError を投げる。
35
+ // 500 に丸めず、トラバーサル拒否と同じく notFound に倒す
36
+ let urlPath;
37
+ try {
38
+ urlPath = decodeURIComponent(new URL(c.req.url).pathname);
39
+ }
40
+ catch {
41
+ return c.notFound();
42
+ }
43
+ if (urlPath.startsWith('/api/')) {
44
+ return c.notFound();
45
+ }
46
+ const rel = urlPath === '/' ? 'index.html' : urlPath.replace(/^\/+/, '');
47
+ const resolved = path.resolve(root, rel);
48
+ // 配信対象を dist/web 配下に限定する (パストラバーサル対策)
49
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
50
+ return c.notFound();
51
+ }
52
+ try {
53
+ const data = await fs.readFile(resolved);
54
+ const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] ?? 'application/octet-stream';
55
+ return c.body(new Uint8Array(data), 200, { 'content-type': type });
56
+ }
57
+ catch {
58
+ // SPA fallback: 拡張子の無いパスは index.html を返す (クライアント側ルーティング用)
59
+ if (path.extname(rel) === '') {
60
+ try {
61
+ const html = await fs.readFile(path.join(root, 'index.html'), 'utf8');
62
+ return c.html(html);
63
+ }
64
+ catch {
65
+ return c.text('Web UI が未ビルドです。パッケージ開発中の場合は `npm run build` を実行してください。', 503);
66
+ }
67
+ }
68
+ return c.notFound();
69
+ }
70
+ });
71
+ }
@@ -0,0 +1,38 @@
1
+ import path from 'node:path';
2
+ import { watch } from 'chokidar';
3
+ /** 監視対象: タスクファイル (.md) と config.json のみ。アトミック書込の .tmp は無視する */
4
+ function isWatchTarget(filePath) {
5
+ const base = path.basename(filePath);
6
+ return base.endsWith('.md') || base === 'config.json';
7
+ }
8
+ export function startWatcher(options) {
9
+ const { kanbanDir, bus } = options;
10
+ const debounceMs = options.debounceMs ?? 200;
11
+ const watcher = watch(kanbanDir, { ignoreInitial: true });
12
+ let timer = null;
13
+ const onFsEvent = (filePath) => {
14
+ if (!isWatchTarget(filePath))
15
+ return;
16
+ if (timer !== null)
17
+ clearTimeout(timer);
18
+ timer = setTimeout(() => {
19
+ timer = null;
20
+ bus.broadcast({ type: 'change' });
21
+ }, debounceMs);
22
+ };
23
+ watcher.on('add', onFsEvent);
24
+ watcher.on('change', onFsEvent);
25
+ watcher.on('unlink', onFsEvent);
26
+ // error listener 不在だと EventEmitter が同期 throw して serve プロセスごと落ちる。
27
+ // watch 不能は SSE 通知が止まるだけ (手動リロードで回復可能) なので握って継続する
28
+ watcher.on('error', () => { });
29
+ return {
30
+ async close() {
31
+ if (timer !== null) {
32
+ clearTimeout(timer);
33
+ timer = null;
34
+ }
35
+ await watcher.close();
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * クリティカルパス分析 (CP ビュー、波 II)。依存追加なしの純関数。
3
+ *
4
+ * 入力: 未完了タスクの id と depends_on の辺。done / archive 済みは呼び出し側で除外する。
5
+ * 出力: トポロジカル深さ (level)・クリティカルパス (最長依存鎖)・循環依存。
6
+ *
7
+ * 日付・期間は一切扱わない (RYU 指示: AI 開発では日程が無意味、依存の重要経路だけを見る)。
8
+ *
9
+ * 辺の向き: 「A depends_on B」= B が先・A が後。B を A の prerequisite (先行) とする。
10
+ * level(node) = その node に至る最長の先行鎖の長さ (先行なし = 0)。
11
+ * クリティカルパス = level が最大のノードへ至る最長鎖 (= プロジェクト完了を律速する依存連鎖)。
12
+ */
13
+ /**
14
+ * 依存グラフを解析する。グラフに存在しない id への depends_on は辺として無視する
15
+ * (done で除外済みの先行・タイプミス等)。
16
+ */
17
+ export function analyzeCriticalPath(input) {
18
+ const ids = new Set(input.map((n) => n.id));
19
+ // グラフ内に存在する辺だけ採用する (存在しない先行は無視)
20
+ const prereqs = new Map();
21
+ for (const n of input) {
22
+ prereqs.set(n.id, n.dependsOn.filter((d) => ids.has(d) && d !== n.id));
23
+ }
24
+ // 循環検出 (DFS 3 色)。循環に関与する全ノードを集める
25
+ const WHITE = 0;
26
+ const GRAY = 1;
27
+ const BLACK = 2;
28
+ const color = new Map();
29
+ const cycleNodes = new Set();
30
+ for (const id of ids)
31
+ color.set(id, WHITE);
32
+ const stack = [];
33
+ const onStack = new Set();
34
+ const dfs = (id) => {
35
+ color.set(id, GRAY);
36
+ stack.push(id);
37
+ onStack.add(id);
38
+ for (const dep of prereqs.get(id) ?? []) {
39
+ if (color.get(dep) === GRAY) {
40
+ // dep から id までが循環。stack 上の dep..top を循環ノードに
41
+ const start = stack.indexOf(dep);
42
+ for (let i = start; i < stack.length; i++)
43
+ cycleNodes.add(stack[i]);
44
+ }
45
+ else if (color.get(dep) === WHITE) {
46
+ dfs(dep);
47
+ }
48
+ }
49
+ color.set(id, BLACK);
50
+ stack.pop();
51
+ onStack.delete(id);
52
+ };
53
+ for (const id of ids) {
54
+ if (color.get(id) === WHITE)
55
+ dfs(id);
56
+ }
57
+ // level 計算: 循環に関与しないノードのみ。level(n) = max(level(prereq)) + 1 (先行なし=0)。
58
+ // メモ化 DFS。循環ノードに触れたら null
59
+ const levelMemo = new Map();
60
+ const computing = new Set();
61
+ const computeLevel = (id) => {
62
+ if (cycleNodes.has(id))
63
+ return null;
64
+ if (levelMemo.has(id))
65
+ return levelMemo.get(id) ?? null;
66
+ if (computing.has(id))
67
+ return null; // 念のための保険 (循環検出済みなら通らない)
68
+ computing.add(id);
69
+ let max = -1;
70
+ for (const dep of prereqs.get(id) ?? []) {
71
+ const dl = computeLevel(dep);
72
+ if (dl === null) {
73
+ computing.delete(id);
74
+ levelMemo.set(id, null);
75
+ return null;
76
+ }
77
+ if (dl > max)
78
+ max = dl;
79
+ }
80
+ computing.delete(id);
81
+ const level = max + 1;
82
+ levelMemo.set(id, level);
83
+ return level;
84
+ };
85
+ let maxLevel = -1;
86
+ for (const id of ids) {
87
+ const lv = computeLevel(id);
88
+ if (lv !== null && lv > maxLevel)
89
+ maxLevel = lv;
90
+ }
91
+ // tie-break は数値考慮の ID 昇順で統一する (nodes / cycleNodes と一致させる R7)
92
+ const byNumericId = (a, b) => a.localeCompare(b, 'en', { numeric: true });
93
+ // クリティカルパス: level 最大のノードから先行を最長にたどる (level が 1 ずつ減る鎖)
94
+ let criticalPath = [];
95
+ if (maxLevel >= 0) {
96
+ // level 最大のノードを終点候補にする (複数あれば数値考慮 id 昇順で決定的に最初の 1 つ)
97
+ const endpoints = [...ids].filter((id) => levelMemo.get(id) === maxLevel).sort(byNumericId);
98
+ if (endpoints.length > 0) {
99
+ const path = [];
100
+ let cur = endpoints[0];
101
+ while (cur !== undefined) {
102
+ path.push(cur);
103
+ const curLevel = levelMemo.get(cur);
104
+ // level が 1 小さい先行を選ぶ (最長鎖を構成する辺)
105
+ const next = (prereqs.get(cur) ?? [])
106
+ .filter((d) => levelMemo.get(d) === (curLevel ?? 0) - 1)
107
+ .sort(byNumericId);
108
+ cur = next[0];
109
+ }
110
+ criticalPath = path.reverse(); // 先行 → 後続の順
111
+ }
112
+ }
113
+ const cpSet = new Set(criticalPath);
114
+ const nodes = input
115
+ .map((n) => ({
116
+ id: n.id,
117
+ level: levelMemo.get(n.id) ?? null,
118
+ dependsOn: prereqs.get(n.id) ?? [],
119
+ onCriticalPath: cpSet.has(n.id),
120
+ }))
121
+ .sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }));
122
+ return {
123
+ nodes,
124
+ criticalPath,
125
+ cycleNodes: [...cycleNodes].sort((a, b) => a.localeCompare(b, 'en', { numeric: true })),
126
+ maxLevel,
127
+ };
128
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 行単位の LCS (最長共通部分列) diff (技術決定⑦)。
3
+ * PUT 競合 (409) 時にローカル編集とサーバー側内容の差分を提示するために使う。
4
+ * 依存を追加せず ~50 行で実装する。server / web が共有する。
5
+ */
6
+ /**
7
+ * 2 つのテキストを行単位で比較し、変更箇所を返す。
8
+ * `equal` = 両方にある行、`remove` = a のみ (削除)、`add` = b のみ (追加)。
9
+ */
10
+ export function diffLines(a, b) {
11
+ const aLines = a.split('\n');
12
+ const bLines = b.split('\n');
13
+ const n = aLines.length;
14
+ const m = bLines.length;
15
+ // LCS の長さ表 (動的計画法)。lcs[i][j] = aLines[i..], bLines[j..] の LCS 長
16
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
17
+ for (let i = n - 1; i >= 0; i--) {
18
+ for (let j = m - 1; j >= 0; j--) {
19
+ lcs[i][j] = aLines[i] === bLines[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
20
+ }
21
+ }
22
+ // 表をたどって diff を組み立てる
23
+ const result = [];
24
+ let i = 0;
25
+ let j = 0;
26
+ while (i < n && j < m) {
27
+ if (aLines[i] === bLines[j]) {
28
+ result.push({ op: 'equal', text: aLines[i] });
29
+ i++;
30
+ j++;
31
+ }
32
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
33
+ result.push({ op: 'remove', text: aLines[i] });
34
+ i++;
35
+ }
36
+ else {
37
+ result.push({ op: 'add', text: bLines[j] });
38
+ j++;
39
+ }
40
+ }
41
+ while (i < n)
42
+ result.push({ op: 'remove', text: aLines[i++] });
43
+ while (j < m)
44
+ result.push({ op: 'add', text: bLines[j++] });
45
+ return result;
46
+ }