advice.ts の3欠陥を潰す)| 項目 | 内容 |
|---|---|
| 実行環境 | ★Cursor 内の Claude が実装します★(.claude/commands/d-watch.md の役割分担表を参照) |
| 実行状態 | 未実行 |
| 実行順 | ★指示書G より先に実行すること★(G の段階2-1 がこの部品を使う形に書き換わっています) |
| 取得する決定番号 | ★D-075 から★(D-074 まで使用済み。★着手直前に grep -n "^### D-0" docs/decisions.md で再確認すること★。他の指示書と同時に走らせないこと) |
| 着手前の基準 | npm test が 19ファイル / 424テスト 全パス、npx tsc --noEmit エラー0、npm run lint 0(★2026-09-08 実測★) |
docs/handoff/指示書G_D-Secログの備考入力と2状態化.md:499 に、こう書かれています。
|
lib/settings/advice.tsのリファクタ | 弱点は分かっているが、備考の実装とは別件。触ると advice の挙動が変わる |
★この判断を撤回します。★ 理由は次のとおりです。
advice.ts の弱点は、放置すると運用担当が書いた文章が消える性質のものです(下記)advice.ts を「ひな型にする」と指示している★(G :284)ため、 直さないと同じ欠陥が2つ目のストアにコピーされますadvice.ts はパスをモジュール先頭で固定していてテストが書けません。 G が要求する「一時ディレクトリで実際のファイル読み書きを検証」(G :314-327)を満たすには、 パスを注入できる形の部品が必要で、それは advice.ts 側の作りにも手を入れることになりますこの指示書で advice.ts を安全な部品に載せ替え、G はその部品を使うだけにします。
lib/settings/advice.ts(全85行)は ★このリポジトリで唯一ファイルを書くコード★ です (node:fs の import はリポジトリ全体でこの1ファイルだけ。ビルド成果物でも確認済み)。 欠陥が3つあります。
// lib/settings/advice.ts:26-34
async function readOverrides(): Promise<AdviceOverrides> {
try {
const raw = await fs.readFile(SETTINGS_PATH, "utf-8");
return JSON.parse(raw) as AdviceOverrides;
} catch {
return {}; // ★「ファイルが無い」と「壊れている」を区別していない★
}
}
saveAdviceRule(:70-72)は「読む → 1件マージ → 書く」の順で動きます。
★具体例★: 10件のアドバイス文を登録済み。ファイルが壊れる(欠陥2)。すると—
JSON.parse 失敗 → {} → 画面には既定値だけが出る(10件が消えたように見える){その1件} だけのファイルが書かれ、残り9件が本当に消える★/data は .gitignore されています★(.gitignore:46-47「運用設定(実行時に生成される。 環境ごとに異なるためコミットしない)」)。git にバックアップがありません。復旧手段がありません。
// lib/settings/advice.ts:38
await fs.writeFile(SETTINGS_PATH, `${JSON.stringify(overrides, null, 2)}\n`, "utf-8");
fs.writeFile は既存ファイルを切り詰めてから書きます。 ★切り詰めた直後にプロセスが落ちると、ファイルは空か途中までになります★ → 欠陥1 の入口です。
★具体例★: dev サーバの再起動、デプロイでのプロセス入れ替え、VM の停止。 保存ボタンを押した1秒間に当たるだけで全設定が消えます。
lib/settings/__tests__/ が存在しません(テスト0件)advice.ts:22 — ★process.cwd() 起点のパスをモジュール先頭の定数で固定している★ため、 テストから保存先を差し替える手段がありませんmkdtemp / tmpdir を使ったテストの前例が1件もありません(検索して0件)。 この指示書でその前例を作ります| 段階 | 内容 | コミット |
|---|---|---|
| 段階0 | ログイベント型の追加(lib/logger/events.ts)+ 設計文書の表を更新 |
段階0で1コミット |
| 段階1 | ★テストを先に書く★(lib/settings/__tests__/json-store.test.ts)→ 赤くなる |
段階1〜2をまとめて1コミット |
| 段階2 | lib/settings/json-store.ts を実装 → 緑にする |
同上 |
| 段階3 | advice.ts を新部品に載せ替え + advice.ts のテストを追加 |
段階3で1コミット |
| 段階4 | 記録と、指示書G への申し送り | 段階4で1コミット |
| ★段階5★ | ★origin/develop へ push する★ |
コミットなし(push のみ) |
★段階ごとに npx tsc --noEmit と npm test を実行し、通ってからコミットすること★(D-050)。
★logEvent("warn", ...) は、いま そのままでは呼べません。★ lib/logger/events.ts:217-226 の LogEvent は ★閉じた allowlist の union で9種類しかなく★、 「設定ストアの JSON が壊れていた」に相当するイベントが存在しません。
追加するイベント型(lib/logger/events.ts):
docs/logging-design.md:105 の命名規約)。 例: settings.store_corruptLogEvent の union に追加するdocs/logging-design.md:108-114 のイベント一覧表にも追記すること★(表と実装を乖離させない)lib/logger/redact.ts:12-26 の禁止フィールド名に ★note / notes / comment / memo / detail★ が 含まれており、redact.ts:161-173 の enforceForbiddenFieldsPolicy は ★非本番環境では例外を throw します★(「ログに禁止フィールドを含められません」)。 キー名は大文字小文字を無視した完全一致で、ネストしたオブジェクトも再帰的に検査されます。
したがって:
| やること | やってはいけないこと |
|---|---|
file_path / backup_path / reason のようなキーを使う |
★detail を使う★(禁止キー) |
| パスと理由だけを載せる | ★壊れたファイルの中身や、備考・アドバイス文の本文を載せる★ |
lib/settings/__tests__/json-store.test.ts を作ります。 vitest.config.ts は environment: "node" / include: ["lib/**/*.test.ts", ...] なので ★追加設定なしで拾われ、node:fs と node:os がそのまま使えます★。
★一時ディレクトリを使うこと★: fs.mkdtemp(path.join(os.tmpdir(), "dwatch-")) で作り、 各テストで独立したディレクトリを使う(テスト間で状態を持ち回らない)。 このリポジトリ初の「実際にファイルを読み書きするテスト」になります。
read()| 状況 | 期待 | 何を固定するか |
|---|---|---|
| ファイルが無い | ★空の値★(empty() の結果)。.bak は作られない |
「一度も保存していない」は正常系 |
| 正しい JSON がある | その内容 | 素の読み込み |
| ★JSON が壊れている★ | ★.bak が作られ、空の値が返り、警告が1回出る★ |
★黙って捨てないこと★ |
★JSON が壊れていて、.bak の作成に失敗する★ |
★例外を投げる(空の値を返さない)★ | ★最重要。下の「なぜ投げるのか」を読むこと★ |
| 中身が JSON だが配列や文字列 | ★壊れている扱い★(.bak + 空 + 警告) |
オブジェクト以外を通すと後段が壊れる |
キーに __proto__ がある |
★Object.prototype が汚染されない★。({}).polluted が undefined のまま |
プロトタイプ汚染 |
キーに constructor / prototype がある |
同上 | 同上 |
★なぜ .bak 作成に失敗したら例外を投げるのか★
.bak を作れないまま「空の値」を返すと、呼び出し側が次に write() した瞬間に 壊れたファイルが上書きされ、復旧できなくなります(欠陥1 そのもの)。 壊れたファイルを退避できないなら、処理を止めるのが正しい。 画面には 500 が出ますが、★データが消えるより 500 のほうがましです★。 この理由をコードのコメントに残すこと。
write()| 状況 | 期待 | 何を固定するか |
|---|---|---|
| ディレクトリが無い | 作られて書ける(mkdir の recursive) |
初回保存 |
| 既存ファイルがある | 置き換わる | ★fs.rename が既存ファイルを上書きできること★ |
書いた直後に read() |
同じ値が返る | 往復 |
| 書き込み後 | ★.tmp が残っていない★ |
一時ファイルの後始末 |
| 書き込み中にプロセスが落ちた場合 | (★テストしにくいのでコメントで意図を残す★) | アトミック性の根拠 |
advice と ds-device-notes の両方で効く★)| 状況 | 期待 |
|---|---|
| 2件保存 → 1件だけ更新 | ★もう1件が消えない★ |
| 2件保存 → 1件だけ削除 | ★もう1件が消えない★ |
lib/settings/json-store.ts を実装する★パスを引数で受け取ること。★ これが advice.ts の欠陥3(テスト不可能)の直接の原因です。
/**
* DFA 独自ストア(JSON1ファイル)の読み書きを安全に行う共通部品。
*
* ★メインDBには書きません★(DBは TeamWorks 管理で読み取り専用。CLAUDE.md §7 禁止事項)。
* 保存先は `data/settings/` 配下で、`/data` は .gitignore されています
* (= ★git のバックアップがありません★。だから壊れたファイルは .bak に退避します)。
*
* ★ロックは入れません★ — 施設20件・実質1人の運用で、rename によるアトミック差し替えで
* 「壊れたファイルが残る」ことは防げるため。同時保存は後勝ちになります。
* 利用者が増えたらここにロックを入れること(呼び出し側は変えずに済みます)。
*/
export interface JsonStore<T extends object> {
read(): Promise<T>;
write(value: T): Promise<void>;
}
export function createJsonStore<T extends object>(options: {
/** 保存先の絶対パス。★テストから差し替えられるように引数で受ける★ */
filePath: string;
/** 空の初期値を作る。★Object.create(null) を土台にすること★ */
empty: () => T;
/** 壊れた JSON を検知したときの通知。★lib/logger を import しないための注入★ */
onWarn?: (info: { filePath: string; backupPath: string; reason: string }) => void;
}): JsonStore<T>;
★lib/logger を json-store.ts から import しないこと。★ lib/logger/index.ts は @/lib/db を経由して fixtures 全体を引き込みます。 ストア層がそれを抱えると、テストの依存グラフが不必要に重くなります。 警告はコールバックで受け、logEvent を呼ぶのは呼び出し側(API ルート)にしてください。 lib/logger/index.ts の emitTo(出力先を引数で受ける版)が同じ流儀の前例です。
read() の実装fs.readFile → ENOENT なら empty() を返す(.bak は作らない)JSON.parse を試す。失敗したら、またはオブジェクトでなかったら破損扱い.bak に退避 → onWarn を呼ぶ → empty() を返す★
.bak の名前は ★タイムスタンプ付き★(例: advice.json.2026-09-07T12-34-56.bak)。 固定名にすると2回目の破損で1回目の証拠が消えますObject.create(null) を土台にし、Object.prototype.hasOwnProperty.call() で 確認しながら移し替える★advice.ts には汚染の問題がありませんでした★(キーが閉じた union だったため)。 ds-device-notes は serial_number が任意文字列なので、__proto__ がキーとして飛んできます (指示書G :102-109)。共通部品の側で防ぐのが正しい場所です。
write() の実装fs.mkdir(path.dirname(filePath), { recursive: true })<filePath>.tmp に書く★fs.rename(tmp, filePath)★ でアトミックに差し替える.tmp を片付ける(★残骸を残さない★)★fs.rename が既存ファイルを上書きできることを、テストで必ず確認すること。★ 開発機は Windows、本番は Linux です。挙動が違わないことをテストで固定してください。
advice.ts を載せ替える★loadAdviceRules / saveAdviceRule / resetAdviceRule のシグネチャを変えないこと。★ 呼び出し元は2箇所だけです(実測)。どちらも変更不要にしてください。
app/api/settings/advice/route.ts:13 — loadAdviceRules / saveAdviceRule / resetAdviceRuleapp/api/clean-logs/[id]/advice/route.ts:15 — loadAdviceRules(読み取りのみ)readOverrides / writeOverrides(どちらも非 export)を ★createJsonStore の呼び出しに置き換える★path.join(process.cwd(), "data", "settings", "advice.json")onWarn には logEvent("warn", ...) を渡す。段階0 で追加したイベント型を使うresetAdviceRule(:78-85)に ★id の存在チェックがありません★。 saveAdviceRule(:66-68)にはあります。同じ形に揃えてください。
★現在は API から到達できません★(app/api/settings/advice/route.ts:66-69 が ルート側で DEFAULT_ADVICE_RULES と突き合わせて 422 で弾いているため)。 それでも揃えるのは、ライブラリを直接呼ぶコードが将来増えたときのためです。
advice.ts のテストを追加するlib/settings/__tests__/advice.test.ts を作ります。一時ディレクトリを使うこと。
| # | 何を固定するか |
|---|---|
| 1 | 未編集なら loadAdviceRules() が lib/config/advice-rules.ts の既定値をそのまま返す |
| 2 | 1件保存 → loadAdviceRules() にその1件だけが反映され、他は既定値のまま |
| 3 | ★2件保存 → 1件だけ上書き → もう1件が消えない★(欠陥1 の再発防止) |
| 4 | resetAdviceRule で既定値に戻り、他の編集が消えない |
| 5 | ★壊れた JSON があるとき、.bak が作られ、既定値で動き、警告が出る★ |
| 6 | ★壊れた JSON を読んだ直後に保存しても、.bak の中身が失われていない★ |
| 7 | 未知の id を saveAdviceRule / resetAdviceRule に渡すとどちらも throw する |
★5と6が、この指示書で一番守りたい挙動です。★
docs/decisions.md(★着手直前に最終番号を確認★):499 の「advice.ts のリファクタはやらない」を上書きしたこと★とその理由.bak をタイムスタンプ付きにした理由(固定名だと2回目の破損で証拠が消える).bak の作成に失敗したら例外を投げるという判断とその理由★note / comment / memo / detail が ★redact.ts の禁止キーでログに入れられない★ことnpm test のファイル数・テスト数 / npx tsc --noEmit / npm run lint)docs/handoff/指示書G_D-Secログの備考入力と2状態化.md を更新すること★G の段階2-1 は、この部品を使う形に書き換えてください。★
advice.ts をひな型にする」→ ★「lib/settings/json-store.ts を使う」★.bak 退避・プロトタイプ汚染対策は共通部品側で済んでいるので、 ds-device-notes.ts は ★createJsonStore を呼んで薄く包むだけ★ になるadvice.ts のリファクタの行を削除★(この指示書で実施済み):509 の基準テスト数 267 は古い。★実測値に更新すること★| 文書 | 内容 |
|---|---|
docs/decisions.md の D-065 |
★「保存先の具体(SQLite か JSON か)は未決」という記述が残っています★。JSON で確定したことを追記(決定は指示書G と docs/handoff.md にしかない状態) |
docs/logging-design.md:108-114 |
段階0 で追加したイベントを一覧表に追記(済んでいなければここで) |
docs/handoff.md |
末尾に追記を1つ追加。冒頭の「最新の追記」ポインタも更新 |
origin/develop へ push する★段階0〜4 がすべて緑でコミット済みになってから行ってください。
git push origin develop
origin/develop はまだ存在しません★2026-09-08 時点で、このブランチは一度も push されていません (git log origin/develop が unknown revision。git push --dry-run が * [new branch] develop -> develop を返す)。 つまり ★この push が develop ブランチをリモートに作る最初の push★ になり、 それまでの全コミットがまとめて送られます。
--force は絶対に使わないこと。 新規ブランチの作成なので不要ですmain へは push しないこと★。CLAUDE.md §6-5 のとおり main へのマージは PR 経由で、もう1人のレビューを通してから行います--dry-run は成功しました(権限は付与済みと見られます)。 ★それでも 403 が出たら、無理に回避せずユーザーへ報告してください★ (認証情報を画面に出すコマンドを実行しないこと。git credential fill は禁止)git log --oneline -1 origin/develop git status -sb
origin/develop がローカルの HEAD と同じコミットを指していることgit status -sb の1行目が ## develop...origin/develop(ahead/behind なし)であること| # | 内容 |
|---|---|
| 1 | ★json-store.ts から lib/logger を import しないこと★。@/lib/db 経由で fixtures 全体を引き込みます。警告はコールバックで受ける |
| 2 | ★ログのフィールドに note / notes / comment / memo / detail を使わないこと★。redact.ts の禁止キーで、非本番では例外が飛びます |
| 3 | ★壊れたファイルの中身をログに出さないこと★。運用担当が書いた文章が入っています |
| 4 | ★.bak の退避に失敗したときに「空の値」を返さないこと★。次の保存で壊れたファイルが消えます |
| 5 | ★advice.ts の3つの export のシグネチャを変えないこと★。呼び出し元2箇所を変更不要にする |
| 6 | ★テストを一時ディレクトリで行うこと★。data/settings/advice.json(実在しません)を触らない |
| 7 | cwd のドライブレターは必ず大文字 C:\。小文字だと Vitest が全滅します |
| 8 | 検証が通ったら即コミットする(D-050) |
| 9 | 決定番号は追記直前に最終番号を確認する。★2026-09-07 に D-068 の二重採番が実際に起きました★ |
npx tsc --noEmit エラー0 / npm run lint 0npm test 全パス。★テスト数が 389 から減っていないこと★lib/settings/__tests__/json-store.test.ts と lib/settings/__tests__/advice.test.ts が存在し、 ★一時ディレクトリで実際にファイルを読み書きしていること★.bak に退避 → 既定値で動く → 警告が出る★ がテストで固定されていること.bak 作成に失敗したら例外★ がテストで固定されていること__proto__ をキーにしても Object.prototype が汚染されない★ がテストで固定されていることadvice.ts の3つの export のシグネチャが変わっておらず、呼び出し元2箇所が無変更であることdocs/decisions.md / docs/logging-design.md / docs/handoff.md の更新origin/develop へ push 済みで、git status -sb に ahead/behind が無いこと★ (★main へは push しない★。main へのマージは PR 経由。CLAUDE.md §6-5)